dsh-codearts 0.2.1 → 0.4.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.
package/lib/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as CodeArtsAccountStore, a as SNAP_HOST, b as codeartsAuthPath, c as fetchCallerIdentity, d as maxTokensCeilingFor, f as prepareChatBody, g as isBenefitModel, h as filterEnabledModels, i as refreshCatalog, l as fetchModels, m as FALLBACK_CODEARTS_MODELS, n as codeartsUsagePath, o as canonicalModel, p as CodeArtsCatalog, r as discoverModels, s as chatStream, t as CodeArtsUsageTracker, u as isConcurrencyCap, v as CodeArtsCredentialStore, x as startLogin, y as codeartsAccountsPath } from "./usage-ClRZWUS4.mjs";
1
+ import { C as codeartsAccountsPath, S as CodeArtsCredentialStore, T as startLogin, _ as CodeArtsCatalog, a as SNAP_HOST, b as isBenefitModel, c as claimBenefit, d as fetchModels, f as isConcurrencyCap, g as signedGet, h as prepareChatBody, i as refreshCatalog, l as degradeTokenCeiling, m as maxTokensCeilingFor, n as codeartsUsagePath, o as canonicalModel, p as isParamInvalidFrame, r as discoverModels, s as chatStream, t as CodeArtsUsageTracker, u as fetchCallerIdentity, v as FALLBACK_CODEARTS_MODELS, w as codeartsAuthPath, x as CodeArtsAccountStore, y as filterEnabledModels } from "./usage-3W88T_H1.mjs";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { createServer } from "node:http";
@@ -136,6 +136,227 @@ var CodeArtsAccountPool = class {
136
136
  }
137
137
  };
138
138
  //#endregion
139
+ //#region src/credits.ts
140
+ /**
141
+ * CodeArts 积分与额度。
142
+ *
143
+ * 老结论「CodeArts 没有额度接口」是**错的**。2026-10-10 从官方 CodeArts Agent
144
+ * 客户端(`<安装目录>/resources/app/extensions/vscode-codebot/out/extension.js`)
145
+ * 里挖出它自己的接口清单,并逐条实测通过:
146
+ *
147
+ * GET {SNAP_HOST}/snap-manager/v1/statistics/plugin
148
+ * 积分体系:套餐名/状态、总量、已用、剩余、即将过期、额度周期(重置日)
149
+ * GET https://opengw.developer.huaweicloud.com/api/v1/user/tokens/balance
150
+ * 日/月 token 额度与余额
151
+ *
152
+ * 鉴权必须带 AK/SK 签名(SDK-HMAC-SHA256)。官方客户端那里只写了 `x-auth-token`,
153
+ * 是因为它走 IDE 内部的请求通道;我们直连时只带 `x-auth-token` 会被 APIG 拒掉:
154
+ * `401 APIG.0301 decrypt token fail`(实测三种组合,只有签名那一路 200)。
155
+ *
156
+ * 卡片每次刷新都会问一次状态,所以这里带 TTL 缓存 + 单飞:命中缓存不打上游,
157
+ * 失败也缓存一小段,免得上游抽风时每次刷新都要等一次超时。
158
+ *
159
+ * @module dsh-codearts/credits
160
+ */
161
+ /** 福利网关:token 余额接口在这里(与 benefit/claim、gateway/config 同一个 host)。 */
162
+ const BENEFIT_GATEWAY_HOST = "https://opengw.developer.huaweicloud.com";
163
+ const EP_STATISTICS_PLUGIN = "/snap-manager/v1/statistics/plugin";
164
+ const EP_TOKEN_BALANCE = "/api/v1/user/tokens/balance";
165
+ function asRecord(value) {
166
+ return typeof value === "object" && value !== null ? value : {};
167
+ }
168
+ function asArray(value) {
169
+ return Array.isArray(value) ? value : [];
170
+ }
171
+ function firstString(...values) {
172
+ for (const value of values) if (typeof value === "string" && value.trim() !== "") return value.trim();
173
+ return "";
174
+ }
175
+ /**
176
+ * 取一个非负数字。
177
+ *
178
+ * 上游用 `-1` 表示「这个指标不适用」(例如模型维度的 usage_token / model_quota_num),
179
+ * 所以负数一律当「没有」而不是当 0 —— 否则界面会把「不适用」显示成「已用 0」。
180
+ */
181
+ function nonNegative(value) {
182
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
183
+ }
184
+ function numberOrZero(value) {
185
+ return nonNegative(value) ?? 0;
186
+ }
187
+ /** 按 name 找一条 metrics(上游的 metrics 是一个带 name 的数组)。 */
188
+ function metricNamed(raw, name) {
189
+ for (const item of asArray(raw)) {
190
+ const metric = asRecord(item);
191
+ if (firstString(metric.name) === name) return metric;
192
+ }
193
+ }
194
+ function categoryOf(metric) {
195
+ if (metric === void 0) return void 0;
196
+ const total = nonNegative(metric.package_credit_amount);
197
+ const used = nonNegative(metric.package_credit_used);
198
+ const remain = nonNegative(metric.package_credit_remain);
199
+ if (total === void 0 && used === void 0 && remain === void 0) return void 0;
200
+ return {
201
+ total: numberOrZero(total),
202
+ used: numberOrZero(used),
203
+ remain: numberOrZero(remain),
204
+ expiring: numberOrZero(metric.package_credit_expiring_amount)
205
+ };
206
+ }
207
+ /** 套餐状态映射:与官方客户端 CreditPackageService.mapPackageStatus 一致。 */
208
+ function mapPlanStatus(raw) {
209
+ if (raw === "normal") return "active";
210
+ if (raw === "closed") return "expired";
211
+ if (raw === "freeze") return "frozen";
212
+ return "unknown";
213
+ }
214
+ /**
215
+ * 解析 `GET /snap-manager/v1/statistics/plugin` 的响应。
216
+ *
217
+ * 返回 undefined 表示这不是一份能用的积分文档(上游改了形状/账号没有积分体系)——
218
+ * 调用方据此报「积分不可用」,而不是显示一排 0 假装是真的。
219
+ */
220
+ function parseCreditStatistics(raw) {
221
+ const body = asRecord(raw);
222
+ const metrics = body.metrics;
223
+ if (!Array.isArray(metrics)) return void 0;
224
+ const total = categoryOf(metricNamed(metrics, "usageTotalPackageCredit"));
225
+ if (total === void 0) return void 0;
226
+ const pkg = asRecord(body.package);
227
+ const planNameCn = firstString(pkg.package_name_cn);
228
+ const planNameEn = firstString(pkg.package_name_en);
229
+ const packageUrl = firstString(pkg.package_url);
230
+ const periodStart = firstString(body.start_date);
231
+ const periodEnd = firstString(body.end_date);
232
+ return {
233
+ planName: planNameCn !== "" ? planNameCn : planNameEn,
234
+ planNameEn,
235
+ specCode: firstString(pkg.spec_code),
236
+ planStatus: mapPlanStatus(firstString(pkg.status)),
237
+ isCreditPackage: pkg.is_credit_package === true,
238
+ ...packageUrl === "" ? {} : { packageUrl },
239
+ ...periodStart === "" ? {} : { periodStart },
240
+ ...periodEnd === "" ? {} : { periodEnd },
241
+ total,
242
+ ...pick("bonus", categoryOf(metricNamed(metrics, "usageBonusPackageCredit"))),
243
+ ...pick("basic", categoryOf(metricNamed(metrics, "usageBasicPackageCredit"))),
244
+ ...pick("onDemand", categoryOf(metricNamed(metrics, "usageOnDemandPackageCredit"))),
245
+ fetchedAt: Date.now()
246
+ };
247
+ }
248
+ function pick(key, value) {
249
+ return value === void 0 ? {} : { [key]: value };
250
+ }
251
+ /** 解析 `GET /api/v1/user/tokens/balance` 的响应(`result` 里是额度口径)。 */
252
+ function parseTokenBalance(raw) {
253
+ const result = asRecord(asRecord(raw).result);
254
+ const balance = nonNegative(result.total_balance);
255
+ const quota = nonNegative(result.total_quota);
256
+ if (balance === void 0 && quota === void 0) return void 0;
257
+ return {
258
+ dailyLimit: numberOrZero(result.daily_token_limit),
259
+ dailyUsed: numberOrZero(result.daily_tokens_used),
260
+ monthlyLimit: numberOrZero(result.monthly_token_limit),
261
+ monthlyUsed: numberOrZero(result.monthly_tokens_used),
262
+ balance: numberOrZero(balance),
263
+ quota: numberOrZero(quota)
264
+ };
265
+ }
266
+ function errorOf(action, result) {
267
+ return new Error(`${action} HTTP ${result.status} ${result.message}`.trim().slice(0, 300));
268
+ }
269
+ /**
270
+ * 拉一次积分与 token 额度。
271
+ *
272
+ * 积分是主口径:它失败就整体失败(卡片要能看见原因)。token 额度是附带的,
273
+ * 它失败只让 `tokens` 缺席 —— 别让一个次要接口把主数字一起拖没。
274
+ */
275
+ async function fetchCredits(credential, signal) {
276
+ const [statistics, balance] = await Promise.all([signedGet(`${SNAP_HOST}${EP_STATISTICS_PLUGIN}`, credential, void 0, signal), signedGet(`${BENEFIT_GATEWAY_HOST}${EP_TOKEN_BALANCE}`, credential, void 0, signal).catch(() => ({
277
+ ok: false,
278
+ status: 0,
279
+ message: "request failed"
280
+ }))]);
281
+ if (!statistics.ok) throw errorOf("statistics", statistics);
282
+ const credits = parseCreditStatistics(safeJson(statistics.text));
283
+ if (credits === void 0) throw new Error("statistics response carries no credit metrics");
284
+ const tokens = balance.ok ? parseTokenBalance(safeJson(balance.text)) : void 0;
285
+ return {
286
+ credits,
287
+ ...tokens === void 0 ? {} : { tokens }
288
+ };
289
+ }
290
+ function safeJson(text) {
291
+ try {
292
+ return JSON.parse(text);
293
+ } catch {
294
+ return;
295
+ }
296
+ }
297
+ /**
298
+ * 积分缓存(TTL + 单飞)。
299
+ *
300
+ * 单飞很重要:卡片刷新、对话底部的轮询可能同时打进来,没有它就会并发问上游好几次;
301
+ * 而上游对同一账号的并发是有成本/风险的。
302
+ */
303
+ var CodeArtsCreditCache = class {
304
+ state;
305
+ inflight;
306
+ ttlMs;
307
+ failureTtlMs;
308
+ load;
309
+ now;
310
+ constructor(options = {}) {
311
+ this.ttlMs = options.ttlMs ?? 6e4;
312
+ this.failureTtlMs = options.failureTtlMs ?? 15e3;
313
+ this.load = options.load ?? fetchCredits;
314
+ this.now = options.now ?? (() => Date.now());
315
+ }
316
+ /** 当前缓存内容(不触发请求),供只想读快照的场景使用。 */
317
+ peek() {
318
+ return this.state;
319
+ }
320
+ /** 丢掉缓存,下次 get 会重新拉(重新登录/添加账号后调用)。 */
321
+ invalidate() {
322
+ this.state = void 0;
323
+ this.inflight = void 0;
324
+ }
325
+ /**
326
+ * 取一次积分。命中缓存直接返回;否则合并成一次上游请求。
327
+ * **不抛异常**:失败以 `phase: 'error'` 返回,调用方(状态路由)照样能渲染。
328
+ */
329
+ async get(credential, signal) {
330
+ const cached = this.state;
331
+ if (cached !== void 0) {
332
+ const ttl = cached.phase === "ok" ? this.ttlMs : this.failureTtlMs;
333
+ if (this.now() - cached.at < ttl) return cached;
334
+ }
335
+ return this.inflight ??= this.run(credential, signal).finally(() => {
336
+ this.inflight = void 0;
337
+ });
338
+ }
339
+ async run(credential, signal) {
340
+ try {
341
+ const state = {
342
+ phase: "ok",
343
+ value: await this.load(credential, signal),
344
+ at: this.now()
345
+ };
346
+ this.state = state;
347
+ return state;
348
+ } catch (error) {
349
+ const state = {
350
+ phase: "error",
351
+ message: error instanceof Error ? error.message : String(error),
352
+ at: this.now()
353
+ };
354
+ this.state = state;
355
+ return state;
356
+ }
357
+ }
358
+ };
359
+ //#endregion
139
360
  //#region src/adapter.ts
140
361
  /**
141
362
  * The `codearts` pi-ai provider.
@@ -146,7 +367,7 @@ var CodeArtsAccountPool = class {
146
367
  * models and points pi-ai at the shim's OpenAI-compatible endpoint.
147
368
  *
148
369
  * MVP simplifications vs dsh-codebuddy-cli:
149
- * - no billing rate in the model name (CodeArts has no balance API)
370
+ * - no billing rate in the model name (per-request cost not exposed by the API)
150
371
  * - no per-model reasoning ladder (models are surfaced as plain chat models)
151
372
  *
152
373
  * @module dsh-codearts/adapter
@@ -401,6 +622,8 @@ function readBody$1(req) {
401
622
  }
402
623
  /** 一次请求最多试几个账号(与 Go 参考的 MaxRotate 一致)。 */
403
624
  const MAX_ROTATE = 3;
625
+ /** 一次请求最多把 max_tokens 降几档(65536 → 32768 → 16384 → 8192)。 */
626
+ const MAX_CEILING_HEALS = 3;
404
627
  /**
405
628
  * 所有账号都撞上并发上限时,回落到「在同一账号上原地重发」的预算。
406
629
  *
@@ -505,13 +728,85 @@ function createCodeArtsShim(options) {
505
728
  else res.end();
506
729
  }
507
730
  }
731
+ /** 该模型的上下文窗口(catalog 里查,大小写不敏感);查不到返回 0 = 不做超长预判。 */
732
+ function contextWindowFor(id) {
733
+ const lower = id.trim().toLowerCase();
734
+ for (const model of catalog.current()) if (model.id.toLowerCase() === lower) return model.contextWindow;
735
+ return 0;
736
+ }
737
+ /**
738
+ * 打开上游流;撞到「参数非法」就把该模型的 max_tokens 上限折半降一档再开一次。
739
+ *
740
+ * 上游的 max_tokens 上限由服务端控制、会漂(GLM-5.2 标称 131072,2026-10-10 起
741
+ * 直接被拒),手工实测表必然滞后。而这类错误是**流内**帧(HTTP 仍 200),只能在
742
+ * 第一帧判 —— 此时还没有任何内容产出,重发是安全的。降档结果记在 upstream 的
743
+ * 进程内表里,该模型后续请求直接从新上限起步,不必每轮再白撞一次。
744
+ */
745
+ async function openWithCeilingHeal(credential, raw, contextWindow, signal) {
746
+ for (let heal = 0;; heal += 1) {
747
+ const prepared = prepareChatBody(raw, contextWindow);
748
+ if (prepared.error !== void 0) return {
749
+ prepared,
750
+ result: {
751
+ ok: false,
752
+ kind: "client",
753
+ status: 400,
754
+ message: prepared.error
755
+ }
756
+ };
757
+ const result = await chatStream(credential, prepared, signal);
758
+ if (!result.ok) return {
759
+ prepared,
760
+ result
761
+ };
762
+ const reader = result.stream.getReader();
763
+ const first = await reader.read();
764
+ const head = first.done ? void 0 : first.value;
765
+ const shouldHeal = heal < MAX_CEILING_HEALS && head !== void 0 && isParamInvalidFrame(new TextDecoder().decode(head));
766
+ reader.releaseLock();
767
+ if (!shouldHeal) return {
768
+ prepared,
769
+ result,
770
+ head
771
+ };
772
+ await result.stream.cancel().catch(() => {});
773
+ if (degradeTokenCeiling(prepared.model) === 0) return {
774
+ prepared,
775
+ result: {
776
+ ok: false,
777
+ kind: "client",
778
+ status: 400,
779
+ message: `${prepared.model}: max_tokens 已降到下限仍被判参数非法`
780
+ }
781
+ };
782
+ logger?.warn(`dsh-codearts: ${prepared.model} 被判参数非法,max_tokens 降档到 ${maxTokensCeilingFor(prepared.model)} 后重发`);
783
+ }
784
+ }
785
+ /** 先交回降档判定读过的那一帧(它是流的开头,丢了就少一段),再接着读剩下的。 */
786
+ async function* drainFrames(reader, head) {
787
+ if (head !== void 0) yield head;
788
+ for (;;) {
789
+ const { done, value } = await reader.read();
790
+ if (done) return;
791
+ if (value !== void 0) yield value;
792
+ }
793
+ }
508
794
  async function chatCompletions(req, res) {
509
795
  if (!isJsonContentType(req)) {
510
796
  writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
511
797
  return;
512
798
  }
513
799
  const raw = (await readBody$1(req)).toString("utf8");
514
- const prepared = prepareChatBody(raw);
800
+ let contextWindow = 0;
801
+ try {
802
+ const inbound = JSON.parse(raw);
803
+ if (typeof inbound.model === "string") contextWindow = contextWindowFor(canonicalModel(inbound.model));
804
+ } catch {}
805
+ const checked = prepareChatBody(raw, contextWindow);
806
+ if (checked.error !== void 0) {
807
+ writeOpenAIError(res, 400, "context_overflow", checked.error);
808
+ return;
809
+ }
515
810
  const controller = new AbortController();
516
811
  res.on("close", () => {
517
812
  if (!res.writableEnded) controller.abort();
@@ -544,7 +839,8 @@ function createCodeArtsShim(options) {
544
839
  pool.disable(id, "no credential");
545
840
  continue;
546
841
  }
547
- const result = await chatStream(credential, prepared, controller.signal);
842
+ const opened = await openWithCeilingHeal(credential, raw, contextWindow, controller.signal);
843
+ const { prepared, result } = opened;
548
844
  if (controller.signal.aborted) {
549
845
  if (result.ok) await result.stream.cancel().catch(() => {});
550
846
  return;
@@ -554,7 +850,7 @@ function createCodeArtsShim(options) {
554
850
  await pipeToResponse(res, result.stream, controller.signal, {
555
851
  accountId: id,
556
852
  model: prepared.model
557
- });
853
+ }, opened.head);
558
854
  return;
559
855
  }
560
856
  lastKind = result.kind;
@@ -581,28 +877,31 @@ function createCodeArtsShim(options) {
581
877
  }
582
878
  if (controller.signal.aborted) return;
583
879
  if (cappedAccount !== void 0) {
584
- if (pool.acquire(cappedAccount)) try {
585
- const credential = await store.resolve(cappedAccount);
586
- if (credential !== void 0) {
587
- const result = await openStream(credential, prepared, controller.signal);
588
- if (controller.signal.aborted) {
589
- if (result.ok) await result.stream.cancel().catch(() => {});
590
- return;
591
- }
592
- if (result.ok) {
593
- pool.noteSuccess(cappedAccount);
594
- await pipeToResponse(res, result.stream, controller.signal, {
595
- accountId: cappedAccount,
596
- model: prepared.model
597
- });
598
- return;
880
+ if (pool.acquire(cappedAccount)) {
881
+ const prepared = prepareChatBody(raw, contextWindow);
882
+ try {
883
+ const credential = await store.resolve(cappedAccount);
884
+ if (credential !== void 0) {
885
+ const result = await openStream(credential, prepared, controller.signal);
886
+ if (controller.signal.aborted) {
887
+ if (result.ok) await result.stream.cancel().catch(() => {});
888
+ return;
889
+ }
890
+ if (result.ok) {
891
+ pool.noteSuccess(cappedAccount);
892
+ await pipeToResponse(res, result.stream, controller.signal, {
893
+ accountId: cappedAccount,
894
+ model: prepared.model
895
+ });
896
+ return;
897
+ }
898
+ lastKind = result.kind;
899
+ lastStatus = result.status;
900
+ lastMessage = result.message;
599
901
  }
600
- lastKind = result.kind;
601
- lastStatus = result.status;
602
- lastMessage = result.message;
902
+ } finally {
903
+ pool.release(cappedAccount);
603
904
  }
604
- } finally {
605
- pool.release(cappedAccount);
606
905
  }
607
906
  }
608
907
  if (lastKind === void 0) {
@@ -611,8 +910,22 @@ function createCodeArtsShim(options) {
611
910
  }
612
911
  writeOpenAIError(res, KIND_STATUS[lastKind], lastKind, `codearts upstream ${lastKind} (http ${lastStatus}): ${lastMessage.slice(0, 400)}`);
613
912
  }
913
+ /**
914
+ * 记下一帧里的上游流内错误。判据宽松(帧文本含 "error" 才解析),解析不出来就忽略 ——
915
+ * 诊断不能影响转发。
916
+ */
917
+ function logInStreamError(frame, model) {
918
+ try {
919
+ const payload = frame.startsWith("data:") ? frame.slice(5).trim() : frame;
920
+ const ev = JSON.parse(payload);
921
+ if (ev.error === void 0) return;
922
+ const code = String(ev.error.code ?? "");
923
+ const message = String(ev.error.message ?? "").slice(0, 200);
924
+ logger?.error(`dsh-codearts: 上游流内报错 model=${model} code=${code} msg=${message}`);
925
+ } catch {}
926
+ }
614
927
  /** 把上游 SSE 按事件边界转发给 DSH,直到流结束或被中止。 */
615
- async function pipeToResponse(res, stream, signal, attribution) {
928
+ async function pipeToResponse(res, stream, signal, attribution, head) {
616
929
  res.writeHead(200, {
617
930
  "Content-Type": "text/event-stream",
618
931
  "Cache-Control": "no-cache",
@@ -629,12 +942,7 @@ function createCodeArtsShim(options) {
629
942
  res.end(sawDone ? void 0 : "data: [DONE]\n\n");
630
943
  };
631
944
  try {
632
- for (;;) {
633
- const { done, value } = await reader.read();
634
- if (done) {
635
- finish();
636
- return;
637
- }
945
+ for await (const value of drainFrames(reader, head)) {
638
946
  buf += decoder.decode(value, { stream: true });
639
947
  let idx;
640
948
  while ((idx = buf.indexOf("\n\n")) >= 0) {
@@ -642,10 +950,12 @@ function createCodeArtsShim(options) {
642
950
  buf = buf.slice(idx + 2);
643
951
  if (!frame) continue;
644
952
  if (frame.trim() === "data: [DONE]") sawDone = true;
953
+ if (frame.includes("\"error\"")) logInStreamError(frame, attribution?.model ?? "");
645
954
  if (usage !== void 0 && attribution !== void 0 && frame.includes("\"usage\"")) recordFrameUsage(frame, attribution);
646
955
  if (res.writable) res.write(frame + "\n\n");
647
956
  }
648
957
  }
958
+ finish();
649
959
  } catch (error) {
650
960
  if (!signal.aborted) logger?.warn("dsh-codearts: upstream stream failed mid-flight", error);
651
961
  finish();
@@ -689,6 +999,8 @@ const CODEARTS_LOGIN_PATH = "/api/codearts/login";
689
999
  const CODEARTS_LOGIN_STATUS_PATH = "/api/codearts/login/status";
690
1000
  /** 删除某个账号:DELETE /api/codearts/accounts?id=<accountId> */
691
1001
  const CODEARTS_ACCOUNTS_PATH = "/api/codearts/accounts";
1002
+ /** POST /api/codearts/claim — 手动领取限时福利(签到领积分,幂等)。 */
1003
+ const CODEARTS_CLAIM_PATH = "/api/codearts/claim";
692
1004
  /**
693
1005
  * 由完整目录 + 已存 enabled 计算 selection。
694
1006
  * - enabled 为空/未存 => 全部勾选(restricted=false)
@@ -806,6 +1118,21 @@ function usageOf(deps) {
806
1118
  slots: slotsOf(deps)
807
1119
  };
808
1120
  }
1121
+ /**
1122
+ * 积分区块。
1123
+ *
1124
+ * 积分是「锦上添花」:拉不到也绝不能把状态接口本身拖垮,所以失败一律降级成
1125
+ * `creditsError` 交给卡片显示原因(超时由注入方在自己的闭包里保证)。
1126
+ */
1127
+ async function creditsOf(deps) {
1128
+ if (deps.credits === void 0) return {};
1129
+ try {
1130
+ const state = await deps.credits();
1131
+ return state.phase === "ok" ? { credits: state.value } : { creditsError: state.message };
1132
+ } catch (error) {
1133
+ return { creditsError: safeMessage(error) };
1134
+ }
1135
+ }
809
1136
  async function buildStatus(deps) {
810
1137
  const models = deps.models().map((m) => ({
811
1138
  id: m.id,
@@ -820,14 +1147,23 @@ async function buildStatus(deps) {
820
1147
  accounts,
821
1148
  usage
822
1149
  };
823
- return {
824
- status: accounts.some(accountUsable) ? "signed-in" : "expired",
1150
+ const usable = accounts.some(accountUsable);
1151
+ const identity = {
825
1152
  ...cred.label === void 0 ? {} : { label: cred.label },
826
1153
  ...cred.storedAt === void 0 ? {} : { storedAt: cred.storedAt },
827
1154
  models,
828
1155
  accounts,
829
1156
  usage
830
1157
  };
1158
+ if (!usable) return {
1159
+ status: "expired",
1160
+ ...identity
1161
+ };
1162
+ return {
1163
+ status: "signed-in",
1164
+ ...identity,
1165
+ ...await creditsOf(deps)
1166
+ };
831
1167
  }
832
1168
  /** GET /api/codearts/status */
833
1169
  function statusHandler(deps) {
@@ -997,6 +1333,36 @@ function accountsHandler(deps) {
997
1333
  }
998
1334
  };
999
1335
  }
1336
+ /** POST /api/codearts/claim — 手动领取限时福利(签到领积分,幂等)。 */
1337
+ function claimHandler(deps) {
1338
+ return async (req, res) => {
1339
+ if (req.method !== "POST") {
1340
+ json(res, 405, { error: "method not allowed" });
1341
+ return;
1342
+ }
1343
+ if (deps.claim === void 0) {
1344
+ json(res, 404, { error: "claim not available" });
1345
+ return;
1346
+ }
1347
+ if (!loopbackRequest(req)) {
1348
+ json(res, 403, { error: "request-not-trusted" });
1349
+ return;
1350
+ }
1351
+ try {
1352
+ const result = await deps.claim();
1353
+ if (result.ok) json(res, 200, { ok: true });
1354
+ else json(res, 500, {
1355
+ ok: false,
1356
+ message: result.message
1357
+ });
1358
+ } catch (error) {
1359
+ json(res, 500, {
1360
+ ok: false,
1361
+ message: safeMessage(error)
1362
+ });
1363
+ }
1364
+ };
1365
+ }
1000
1366
  /** Mount the status / models / token routes. */
1001
1367
  function registerCodeArtsStatusRoute(ctx, deps) {
1002
1368
  ctx.effect(() => {
@@ -1038,6 +1404,11 @@ function registerCodeArtsStatusRoute(ctx, deps) {
1038
1404
  path: CODEARTS_ACCOUNTS_PATH,
1039
1405
  handler: accountsHandler(deps)
1040
1406
  });
1407
+ const disposeClaim = ctx.webServer.register({
1408
+ kind: "exact",
1409
+ path: CODEARTS_CLAIM_PATH,
1410
+ handler: claimHandler(deps)
1411
+ });
1041
1412
  return () => {
1042
1413
  disposeStatus();
1043
1414
  disposeModels();
@@ -1046,6 +1417,7 @@ function registerCodeArtsStatusRoute(ctx, deps) {
1046
1417
  disposeLoginStart();
1047
1418
  disposeLoginStatus();
1048
1419
  disposeAccounts();
1420
+ disposeClaim();
1049
1421
  for (const s of activeLogins.values()) s.close();
1050
1422
  activeLogins.clear();
1051
1423
  };
@@ -1218,6 +1590,46 @@ function apply(ctx, rawConfig) {
1218
1590
  const pool = new CodeArtsAccountPool([]);
1219
1591
  const catalog = new CodeArtsCatalog();
1220
1592
  const usage = new CodeArtsUsageTracker({ path: codeartsUsagePath() });
1593
+ const credits = new CodeArtsCreditCache();
1594
+ /** /status 是卡片与对话底部都会轮询的接口,积分慢请求不能把它拖成转圈。 */
1595
+ const CREDIT_TIMEOUT_MS = 8e3;
1596
+ /**
1597
+ * 拉一次积分。
1598
+ *
1599
+ * 积分是**账号级**属性,不随请求轮转:这里挑第一个没用坏的账号,够覆盖正常用法
1600
+ * (多账号下的积分差异不在本插件关心范围内)。
1601
+ */
1602
+ const loadCredits = async (signal) => {
1603
+ const ids = await store.ids();
1604
+ if (ids.length === 0) return {
1605
+ phase: "error",
1606
+ message: "not signed in",
1607
+ at: Date.now()
1608
+ };
1609
+ const controller = new AbortController();
1610
+ const timer = setTimeout(() => controller.abort(), CREDIT_TIMEOUT_MS);
1611
+ timer.unref?.();
1612
+ signal?.addEventListener("abort", () => controller.abort(), { once: true });
1613
+ try {
1614
+ let last;
1615
+ for (const id of ids) {
1616
+ const stored = await store.credential(id);
1617
+ if (stored === void 0 || stored.stale === true) continue;
1618
+ const resolved = await store.resolve(id);
1619
+ if (resolved === void 0) continue;
1620
+ const state = await credits.get(resolved, controller.signal);
1621
+ if (state.phase === "ok") return state;
1622
+ last = state;
1623
+ }
1624
+ return last ?? {
1625
+ phase: "error",
1626
+ message: "no usable credential",
1627
+ at: Date.now()
1628
+ };
1629
+ } finally {
1630
+ clearTimeout(timer);
1631
+ }
1632
+ };
1221
1633
  let current = () => config;
1222
1634
  const enabledModels = () => current().enabledModels;
1223
1635
  const setEnabledModels = async (ids) => {
@@ -1232,7 +1644,31 @@ function apply(ctx, rawConfig) {
1232
1644
  enabledModels,
1233
1645
  setEnabledModels,
1234
1646
  pool: () => pool.list(),
1235
- usage: () => usage.snapshot()
1647
+ usage: () => usage.snapshot(),
1648
+ credits: (signal) => loadCredits(signal),
1649
+ claim: async () => {
1650
+ try {
1651
+ const ids = await store.ids();
1652
+ for (const id of ids) {
1653
+ const stored = await store.credential(id);
1654
+ if (stored === void 0 || stored.stale === true) continue;
1655
+ const resolved = await store.resolve(id);
1656
+ if (resolved === void 0) continue;
1657
+ await claimBenefit(resolved, AbortSignal.timeout(1e4));
1658
+ credits.invalidate();
1659
+ return { ok: true };
1660
+ }
1661
+ return {
1662
+ ok: false,
1663
+ message: "no usable credential"
1664
+ };
1665
+ } catch (error) {
1666
+ return {
1667
+ ok: false,
1668
+ message: error instanceof Error ? error.message : String(error)
1669
+ };
1670
+ }
1671
+ }
1236
1672
  }));
1237
1673
  installSettingsSectionCompat(ctx, CODEARTS_SETTINGS_NS, Config, config, {
1238
1674
  setSource(source) {
@@ -1317,4 +1753,4 @@ function apply(ctx, rawConfig) {
1317
1753
  });
1318
1754
  }
1319
1755
  //#endregion
1320
- export { CODEARTS_PROVIDER, CODEARTS_SETTINGS_NS, CodeArtsAccountPool, CodeArtsAccountStore, CodeArtsCatalog, CodeArtsCredentialStore, CodeArtsUsageTracker, Config, FALLBACK_CODEARTS_MODELS, SNAP_HOST, apply, canonicalModel, chatStream, codeartsAccountsPath, codeartsAuthPath, codeartsUsagePath, createCodeArtsAdapter, createCodeArtsShim, discoverModels, fetchModels, filterEnabledModels, inject, maxTokensCeilingFor, name, prepareChatBody, refreshCatalog };
1756
+ export { BENEFIT_GATEWAY_HOST, CODEARTS_PROVIDER, CODEARTS_SETTINGS_NS, CodeArtsAccountPool, CodeArtsAccountStore, CodeArtsCatalog, CodeArtsCredentialStore, CodeArtsCreditCache, CodeArtsUsageTracker, Config, FALLBACK_CODEARTS_MODELS, SNAP_HOST, apply, canonicalModel, chatStream, codeartsAccountsPath, codeartsAuthPath, codeartsUsagePath, createCodeArtsAdapter, createCodeArtsShim, discoverModels, fetchCredits, fetchModels, filterEnabledModels, inject, maxTokensCeilingFor, name, parseCreditStatistics, parseTokenBalance, prepareChatBody, refreshCatalog };