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.
package/lib/index.mjs ADDED
@@ -0,0 +1,1695 @@
1
+ import { C as codeartsAuthPath, S as codeartsAccountsPath, _ as FALLBACK_CODEARTS_MODELS, a as SNAP_HOST, b as CodeArtsAccountStore, c as degradeTokenCeiling, d as isConcurrencyCap, f as isParamInvalidFrame, g as CodeArtsCatalog, h as signedGet, i as refreshCatalog, l as fetchCallerIdentity, m as prepareChatBody, n as codeartsUsagePath, o as canonicalModel, p as maxTokensCeilingFor, r as discoverModels, s as chatStream, t as CodeArtsUsageTracker, u as fetchModels, v as filterEnabledModels, w as startLogin, x as CodeArtsCredentialStore, y as isBenefitModel } from "./usage-Cn1Bzmxj.mjs";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import { createProvider } from "@earendil-works/pi-ai";
6
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
7
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
8
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
9
+ import * as settingsModule from "@deepseek-ai/dsh-settings";
10
+ /**
11
+ * 撞到上游并发上限后,多久内优先不选这个账号。
12
+ *
13
+ * 这不是冷却:账号本身没坏,只是它的会话槽位暂时满了(约 52 秒才释放)。
14
+ * 所以只是一个「倾向」——池里还有别的账号就先换一个;全都这样时照样会选它,
15
+ * 因为排队等它总比直接失败强。
16
+ */
17
+ const CAPPED_HINT_MS = 3e4;
18
+ var CodeArtsAccountPool = class {
19
+ accounts = /* @__PURE__ */ new Map();
20
+ maxConcurrent;
21
+ now;
22
+ constructor(ids, options = {}) {
23
+ this.maxConcurrent = options.maxConcurrent ?? 2;
24
+ this.now = options.now ?? (() => Date.now());
25
+ this.sync(ids);
26
+ }
27
+ /** 让池与账号文档对齐:新增的进来,删掉的出去。已有账号的运行时状态保留。 */
28
+ sync(ids) {
29
+ const wanted = new Set(ids);
30
+ for (const id of [...this.accounts.keys()]) if (!wanted.has(id)) this.accounts.delete(id);
31
+ for (const id of ids) {
32
+ if (this.accounts.has(id)) continue;
33
+ this.accounts.set(id, {
34
+ id,
35
+ activeConcurrent: 0,
36
+ maxConcurrent: this.maxConcurrent,
37
+ coolUntil: 0,
38
+ cappedUntil: 0,
39
+ disabled: false,
40
+ lastUsed: 0,
41
+ errorCount: 0
42
+ });
43
+ }
44
+ }
45
+ list() {
46
+ return [...this.accounts.values()];
47
+ }
48
+ get(id) {
49
+ return this.accounts.get(id);
50
+ }
51
+ /** 账号本身可用(没被禁用、不在冷却中)。 */
52
+ healthy(id) {
53
+ const account = this.accounts.get(id);
54
+ if (account === void 0) return false;
55
+ return !account.disabled && this.now() >= account.coolUntil;
56
+ }
57
+ /** 现在就能接活的账号(健康 + 本地还有空槽位),按「最久未用」排序。 */
58
+ available(exclude) {
59
+ const now = this.now();
60
+ return this.list().filter((a) => !a.disabled && now >= a.coolUntil && !exclude.has(a.id) && a.activeConcurrent < a.maxConcurrent).sort((a, b) => a.lastUsed - b.lastUsed);
61
+ }
62
+ /**
63
+ * 选一个账号。
64
+ *
65
+ * 优先挑没人撞上限的(`cappedUntil` 已过的);如果全都撞了,就退回其中任意
66
+ * 一个 —— 排它的队总比直接失败强。都没得选(全禁用/全在冷却)时返回 undefined。
67
+ */
68
+ pick(exclude = /* @__PURE__ */ new Set()) {
69
+ const candidates = this.available(exclude);
70
+ if (candidates.length === 0) return void 0;
71
+ const now = this.now();
72
+ const notCapped = candidates.filter((a) => now >= a.cappedUntil);
73
+ return (notCapped.length > 0 ? notCapped[0] : candidates[0]).id;
74
+ }
75
+ /** 占一个本地槽位。占用失败(已满/不存在)返回 false。 */
76
+ acquire(id) {
77
+ const account = this.accounts.get(id);
78
+ if (account === void 0) return false;
79
+ if (account.activeConcurrent >= account.maxConcurrent) return false;
80
+ account.activeConcurrent += 1;
81
+ account.lastUsed = this.now();
82
+ return true;
83
+ }
84
+ /** 释放槽位;重复释放不会把计数压到负数。 */
85
+ release(id) {
86
+ const account = this.accounts.get(id);
87
+ if (account === void 0) return;
88
+ if (account.activeConcurrent > 0) account.activeConcurrent -= 1;
89
+ }
90
+ /**
91
+ * 撞到上游并发上限。
92
+ *
93
+ * **不冷却账号** —— 账号没坏,只是会话槽位暂时满了(约 52 秒释放)。冷却会
94
+ * 让它在一分钟内彻底不可用,反而把后续请求也一起误伤(Go 参考里也是这么
95
+ * 处理的)。这里只记一个「优先避开」的提示,好让并发的请求先去用别的账号。
96
+ */
97
+ noteConcurrencyCap(id) {
98
+ const account = this.accounts.get(id);
99
+ if (account === void 0) return;
100
+ account.cappedUntil = this.now() + CAPPED_HINT_MS;
101
+ }
102
+ /** 凭据失效等硬错误:直接停用,等用户重新登录。 */
103
+ disable(id, reason) {
104
+ const account = this.accounts.get(id);
105
+ if (account === void 0) return;
106
+ account.disabled = true;
107
+ account.lastError = reason;
108
+ }
109
+ /** 上游 5xx / 软限流:短时冷却。 */
110
+ cooldown(id, ms, reason) {
111
+ const account = this.accounts.get(id);
112
+ if (account === void 0) return;
113
+ account.coolUntil = this.now() + ms;
114
+ account.lastError = reason;
115
+ }
116
+ /** 累计错误;达到阈值就冷却。 */
117
+ noteError(id, threshold, cooldownMs, reason) {
118
+ const account = this.accounts.get(id);
119
+ if (account === void 0) return;
120
+ account.errorCount += 1;
121
+ account.lastError = reason;
122
+ if (threshold > 0 && account.errorCount >= threshold) account.coolUntil = this.now() + cooldownMs;
123
+ }
124
+ /** 一次成功:清掉错误计数与冷却。 */
125
+ noteSuccess(id) {
126
+ const account = this.accounts.get(id);
127
+ if (account === void 0) return;
128
+ account.errorCount = 0;
129
+ account.coolUntil = 0;
130
+ account.lastError = void 0;
131
+ }
132
+ /** 有没有任何账号可用(含被撞上限提示的)。 */
133
+ hasUsable() {
134
+ const now = this.now();
135
+ return this.list().some((a) => !a.disabled && now >= a.coolUntil);
136
+ }
137
+ };
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
360
+ //#region src/adapter.ts
361
+ /**
362
+ * The `codearts` pi-ai provider.
363
+ *
364
+ * One loopback-backed adapter registered into the Harness LLM seam, assembled
365
+ * from the same public `dsh-llm-pi-ai` extension points dsh-codebuddy-cli uses.
366
+ * The shim does the CodeArts wire translation; this file only describes the
367
+ * models and points pi-ai at the shim's OpenAI-compatible endpoint.
368
+ *
369
+ * MVP simplifications vs dsh-codebuddy-cli:
370
+ * - no billing rate in the model name (per-request cost not exposed by the API)
371
+ * - no per-model reasoning ladder (models are surfaced as plain chat models)
372
+ *
373
+ * @module dsh-codearts/adapter
374
+ */
375
+ /** Provider route this bundle owns. Keep in sync with cordis.patch.yml. */
376
+ const CODEARTS_PROVIDER = "codearts";
377
+ /**
378
+ * Provider idle ceiling while one stream read is outstanding.
379
+ *
380
+ * 90s 而不是常见的 300s:华为免费推理后端实测会流中途停摆(吐几个 token 后
381
+ * 长时间静默),300s 意味着用户要在"等待"里干等 5 分钟才进入自动重试。
382
+ * 90s 足够容忍首 token 冷启动与推理间隙,又能让 DSH 的重试策略
383
+ * (maxRetries=5, TIMEOUT 可重试)更快接管。
384
+ */
385
+ const CODEARTS_STREAM_IDLE_TIMEOUT_MS = 9e4;
386
+ /** No per-token pricing is knowable for a subscription quota; report zero. */
387
+ const NO_COST = {
388
+ input: 0,
389
+ output: 0,
390
+ cacheRead: 0,
391
+ cacheWrite: 0
392
+ };
393
+ const REQUEST_IMAGE_BUDGETS = {
394
+ maxRequestImageBytes: 20971520,
395
+ requestImagePixelBudget: 4194304,
396
+ requestImageMaxBytes: 1048576
397
+ };
398
+ /** Inert pi-ai auth plane: the codearts route authenticates only via the shim secret. */
399
+ const INERT_AUTH = {
400
+ credentials: {
401
+ async read() {},
402
+ async list() {
403
+ return [];
404
+ },
405
+ async modify() {
406
+ throw new Error("dsh-codearts: the codearts route has no pi-ai credential lifecycle");
407
+ },
408
+ async delete() {}
409
+ },
410
+ authContext: {
411
+ async env() {},
412
+ async fileExists() {
413
+ return false;
414
+ }
415
+ }
416
+ };
417
+ /** Build one pi-ai model descriptor pointing at the loopback shim. */
418
+ function toPiModel(info, baseUrl) {
419
+ return {
420
+ id: info.id,
421
+ name: info.name,
422
+ api: "openai-completions",
423
+ provider: CODEARTS_PROVIDER,
424
+ baseUrl,
425
+ input: info.supportsImages === true ? ["text", "image"] : ["text"],
426
+ reasoning: false,
427
+ cost: NO_COST,
428
+ contextWindow: info.contextWindow,
429
+ maxTokens: info.maxTokens
430
+ };
431
+ }
432
+ function createCodeArtsAdapter(options) {
433
+ const { shim, catalog, enabledModels, resolveAttachments } = options;
434
+ const buildModels = () => {
435
+ const baseUrl = `${shim.baseUrl()}/v1`;
436
+ return catalog.current().map((info) => toPiModel(info, baseUrl));
437
+ };
438
+ const provider = {
439
+ ...createProvider({
440
+ id: CODEARTS_PROVIDER,
441
+ name: "CodeArts",
442
+ auth: { apiKey: {
443
+ name: "CodeArts token",
444
+ async resolve({ credential }) {
445
+ const apiKey = credential?.key;
446
+ return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
447
+ auth: { apiKey },
448
+ source: "CodeArts"
449
+ };
450
+ }
451
+ } },
452
+ models: buildModels(),
453
+ api: openAICompletionsApi()
454
+ }),
455
+ getModels: () => buildModels()
456
+ };
457
+ const profile = {
458
+ provider: CODEARTS_PROVIDER,
459
+ displayName: "CodeArts",
460
+ streamIdleTimeoutMs: CODEARTS_STREAM_IDLE_TIMEOUT_MS,
461
+ retryPolicy: resolveRetryPolicy(void 0, "dsh-codearts retryPolicy"),
462
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
463
+ modelErrors: /* @__PURE__ */ new Map(),
464
+ ...REQUEST_IMAGE_BUDGETS,
465
+ piProvider: provider
466
+ };
467
+ let profiles = /* @__PURE__ */ new Map([[CODEARTS_PROVIDER, profile]]);
468
+ return {
469
+ adapter: new CodeArtsPiAiAdapter(catalog, enabledModels, {
470
+ profiles: () => profiles,
471
+ auth: INERT_AUTH,
472
+ resolveApiKey: async () => shim.token(),
473
+ ...resolveAttachments === void 0 ? {} : { resolveAttachments }
474
+ }),
475
+ invalidate: () => {
476
+ profiles = /* @__PURE__ */ new Map([[CODEARTS_PROVIDER, profile]]);
477
+ }
478
+ };
479
+ }
480
+ /**
481
+ * Adapter that narrows the *offered* model surface to the enabled allowlist
482
+ * (dispatch stays whole). Mirrors dsh-codebuddy-cli's CodeBuddyPiAiAdapter.
483
+ */
484
+ var CodeArtsPiAiAdapter = class extends PiAiAdapter {
485
+ catalog;
486
+ enabledModels;
487
+ constructor(catalog, enabledModels, options) {
488
+ super(options);
489
+ this.catalog = catalog;
490
+ this.enabledModels = enabledModels;
491
+ }
492
+ async listModels(provider) {
493
+ const models = await super.listModels(provider);
494
+ const allowed = filterEnabledModels(this.catalog.current(), this.enabledModels?.());
495
+ const catalogIds = new Set(this.catalog.current().map((entry) => entry.id));
496
+ const allowedIds = new Set(allowed.map((entry) => entry.id));
497
+ return models.filter((model) => !catalogIds.has(model.id) || allowedIds.has(model.id));
498
+ }
499
+ async resolveModel(provider, model, signal) {
500
+ return super.resolveModel(provider, model, signal);
501
+ }
502
+ };
503
+ //#endregion
504
+ //#region src/loopback.ts
505
+ /**
506
+ * Shared loopback gates for the plugin's local HTTP surfaces: the loopback
507
+ * shim and the same-origin web-status route. Both are only ever meant to be
508
+ * addressed through the machine's loopback interface.
509
+ *
510
+ * The 127.0.0.1 bind alone is not a trust boundary (any local process or a
511
+ * DNS-rebinding page can reach 127.0.0.1), so both surfaces also check the
512
+ * Host and Origin headers.
513
+ *
514
+ * @module dsh-codearts/loopback
515
+ */
516
+ /**
517
+ * Loopback hostnames a local plugin surface may be addressed by.
518
+ *
519
+ * `0.0.0.0` and `::` are deliberately absent: they are wildcard *bind*
520
+ * addresses, not loopback identities, and accepting them in a Host header
521
+ * would let a request that never went through the loopback interface pass.
522
+ */
523
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
524
+ "127.0.0.1",
525
+ "localhost",
526
+ "[::1]"
527
+ ]);
528
+ /** Strip the optional :port from a Host header value, IPv6-bracket aware. */
529
+ function hostnameOfHost(host) {
530
+ let hostname = host.trim().toLowerCase();
531
+ if (hostname.startsWith("[")) {
532
+ const end = hostname.indexOf("]");
533
+ return end === -1 ? hostname : hostname.slice(0, end + 1);
534
+ }
535
+ const colon = hostname.lastIndexOf(":");
536
+ if (colon !== -1 && !hostname.slice(0, colon).includes(":") && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
537
+ return hostname;
538
+ }
539
+ /**
540
+ * The request's Host header must name the loopback interface. A DNS-rebinding
541
+ * page (attacker domain re-resolved to 127.0.0.1) sends its own domain in
542
+ * Host, so this check drops those before any routing happens.
543
+ */
544
+ function hostIsLoopback(host) {
545
+ if (host === void 0 || host.trim() === "") return false;
546
+ return LOOPBACK_HOSTS.has(hostnameOfHost(host));
547
+ }
548
+ /**
549
+ * A browser-sent Origin (present header) must be loopback. Non-browser
550
+ * clients (the plugin's own fetch calls) send no Origin at all and pass.
551
+ */
552
+ function originIsLoopback(origin) {
553
+ if (origin === void 0 || origin.trim() === "") return true;
554
+ try {
555
+ const { hostname } = new URL(origin);
556
+ return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
557
+ } catch {
558
+ return false;
559
+ }
560
+ }
561
+ //#endregion
562
+ //#region src/shim.ts
563
+ /**
564
+ * Loopback OpenAI-compatible endpoint for CodeArts.
565
+ *
566
+ * DSH's pi-ai provider points here. The shim validates the loopback + shared
567
+ * secret, resolves the pasted `cloud_dragon_token`, and forwards the request
568
+ * to CodeArts' `/api/v2/chat/completions` — whose SSE chunk shape is already
569
+ * OpenAI-compatible, so we pipe the upstream stream straight through.
570
+ *
571
+ * Inbound hardening (copied from dsh-codebuddy-cli): bind 127.0.0.1 only,
572
+ * require a loopback Host header, a loopback Origin, and the per-process
573
+ * shared secret as `Authorization: Bearer`. Any local attacker hitting the
574
+ * port still cannot forge the secret.
575
+ *
576
+ * @module dsh-codearts/shim
577
+ */
578
+ const REQUEST_BODY_LIMIT = 67108864;
579
+ const KIND_STATUS = {
580
+ hard_credit: 402,
581
+ soft_rate: 429,
582
+ session_dead: 401,
583
+ not_found: 502,
584
+ server: 502,
585
+ client: 400
586
+ };
587
+ function isJsonContentType(req) {
588
+ const type = req.headers["content-type"];
589
+ return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
590
+ }
591
+ function writeJson(res, status, body) {
592
+ const payload = JSON.stringify(body);
593
+ res.writeHead(status, {
594
+ "Content-Type": "application/json",
595
+ "Content-Length": Buffer.byteLength(payload)
596
+ });
597
+ res.end(payload);
598
+ }
599
+ function writeOpenAIError(res, status, kind, message) {
600
+ writeJson(res, status, { error: {
601
+ message,
602
+ type: kind,
603
+ code: kind
604
+ } });
605
+ }
606
+ function readBody$1(req) {
607
+ return new Promise((resolve, reject) => {
608
+ const chunks = [];
609
+ let size = 0;
610
+ req.on("data", (chunk) => {
611
+ size += chunk.length;
612
+ if (size > REQUEST_BODY_LIMIT) {
613
+ reject(/* @__PURE__ */ new Error("request body too large"));
614
+ req.destroy();
615
+ return;
616
+ }
617
+ chunks.push(chunk);
618
+ });
619
+ req.on("end", () => resolve(Buffer.concat(chunks)));
620
+ req.on("error", reject);
621
+ });
622
+ }
623
+ /** 一次请求最多试几个账号(与 Go 参考的 MaxRotate 一致)。 */
624
+ const MAX_ROTATE = 3;
625
+ /** 一次请求最多把 max_tokens 降几档(65536 → 32768 → 16384 → 8192)。 */
626
+ const MAX_CEILING_HEALS = 3;
627
+ /**
628
+ * 所有账号都撞上并发上限时,回落到「在同一账号上原地重发」的预算。
629
+ *
630
+ * 2026-09-30 实测:一个上游会话要 ~52s 才被释放,而且**中途打断并不减免** ——
631
+ * 只跑了 98ms 就中止的请求,同样占满一个名额 52s。所以用户「打断 → 马上重问」
632
+ * 时等的就是这个 52s。池里有别的账号时下面会直接换号(不用等);这条是最后一根
633
+ * 稻草,必须盖过 52s,否则用户看到的不是「稍等一下」而是直接失败。
634
+ *
635
+ * 上限受 pi-ai 的 90s idle 超时约束:等待期间我们发不出任何内容,超过 90s 用户
636
+ * 看到的就是超时。75s 给 52s 留了余量,也还在 90s 之内。
637
+ */
638
+ const CAP_WAIT_LIMIT = 75e3;
639
+ const CAP_RETRY_GAP = 3e3;
640
+ /** 上游 5xx 后短时冷却该账号。 */
641
+ const SERVER_COOLDOWN_MS = 3e4;
642
+ /** 零散错误累计到阈值后冷却该账号。 */
643
+ const ERROR_COOLDOWN_MS = 6e4;
644
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
645
+ /**
646
+ * 在**同一个账号**上重发,直到不再撞上限或预算耗尽。
647
+ *
648
+ * 只在没有别的账号可选时使用 —— 等 52s 是最后手段,能换号就换号。
649
+ * 命中上限不甩给 pi-ai:429 会被重试 5 次,每次再去要一个会话,越试越满。
650
+ */
651
+ async function openStream(credential, prepared, signal) {
652
+ const until = Date.now() + CAP_WAIT_LIMIT;
653
+ for (;;) {
654
+ const result = await chatStream(credential, prepared, signal);
655
+ if (result.ok) return result;
656
+ if (!(isConcurrencyCap(result) && !signal.aborted && Date.now() + CAP_RETRY_GAP <= until)) return result;
657
+ await sleep(CAP_RETRY_GAP);
658
+ }
659
+ }
660
+ function createCodeArtsShim(options) {
661
+ const { store, catalog } = options;
662
+ const logger = options.logger;
663
+ const usage = options.usage;
664
+ const pool = options.pool ?? new CodeArtsAccountPool([]);
665
+ const SHARED_SECRET = randomBytes(32).toString("base64url");
666
+ function bearerOk(req) {
667
+ const header = req.headers.authorization;
668
+ if (typeof header !== "string") return false;
669
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
670
+ if (match === null) return false;
671
+ const presented = match[1];
672
+ const a = Buffer.from(presented);
673
+ const b = Buffer.from(SHARED_SECRET);
674
+ if (a.length !== b.length) return false;
675
+ return timingSafeEqual(a, b);
676
+ }
677
+ const server = createServer((req, res) => {
678
+ handle(req, res);
679
+ });
680
+ const ready = new Promise((resolve, reject) => {
681
+ server.once("listening", () => resolve());
682
+ server.once("error", reject);
683
+ });
684
+ server.listen(0, "127.0.0.1");
685
+ const baseUrl = () => {
686
+ const address = server.address();
687
+ if (address === null || typeof address === "string") throw new Error("codearts shim has no listening address");
688
+ return `http://127.0.0.1:${address.port}`;
689
+ };
690
+ async function handle(req, res) {
691
+ try {
692
+ if (!hostIsLoopback(req.headers.host)) {
693
+ writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
694
+ return;
695
+ }
696
+ if (!originIsLoopback(req.headers.origin)) {
697
+ writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
698
+ return;
699
+ }
700
+ if (!bearerOk(req)) {
701
+ writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
702
+ return;
703
+ }
704
+ const url = req.url ?? "/";
705
+ if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
706
+ writeJson(res, 200, { ok: true });
707
+ return;
708
+ }
709
+ if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
710
+ writeJson(res, 200, {
711
+ object: "list",
712
+ data: catalog.current().map((model) => ({
713
+ id: model.id,
714
+ object: "model",
715
+ created: 0,
716
+ owned_by: "codearts"
717
+ }))
718
+ });
719
+ return;
720
+ }
721
+ if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
722
+ await chatCompletions(req, res);
723
+ return;
724
+ }
725
+ writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
726
+ } catch (error) {
727
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
728
+ else res.end();
729
+ }
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
+ }
794
+ async function chatCompletions(req, res) {
795
+ if (!isJsonContentType(req)) {
796
+ writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
797
+ return;
798
+ }
799
+ const raw = (await readBody$1(req)).toString("utf8");
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
+ }
810
+ const controller = new AbortController();
811
+ res.on("close", () => {
812
+ if (!res.writableEnded) controller.abort();
813
+ });
814
+ let ids;
815
+ try {
816
+ ids = await store.ids();
817
+ } catch (error) {
818
+ writeOpenAIError(res, 401, "not_signed_in", String(error));
819
+ return;
820
+ }
821
+ if (ids.length === 0) {
822
+ writeOpenAIError(res, 401, "not_signed_in", "no CodeArts account stored; sign in from the CodeArts settings card");
823
+ return;
824
+ }
825
+ pool.sync(ids);
826
+ const tried = /* @__PURE__ */ new Set();
827
+ let cappedAccount;
828
+ let lastKind;
829
+ let lastStatus = 0;
830
+ let lastMessage = "";
831
+ for (let attempt = 0; attempt < MAX_ROTATE; attempt += 1) {
832
+ const id = pool.pick(tried);
833
+ if (id === void 0) break;
834
+ tried.add(id);
835
+ if (!pool.acquire(id)) continue;
836
+ try {
837
+ const credential = await store.resolve(id);
838
+ if (credential === void 0) {
839
+ pool.disable(id, "no credential");
840
+ continue;
841
+ }
842
+ const opened = await openWithCeilingHeal(credential, raw, contextWindow, controller.signal);
843
+ const { prepared, result } = opened;
844
+ if (controller.signal.aborted) {
845
+ if (result.ok) await result.stream.cancel().catch(() => {});
846
+ return;
847
+ }
848
+ if (result.ok) {
849
+ pool.noteSuccess(id);
850
+ await pipeToResponse(res, result.stream, controller.signal, {
851
+ accountId: id,
852
+ model: prepared.model
853
+ }, opened.head);
854
+ return;
855
+ }
856
+ lastKind = result.kind;
857
+ lastStatus = result.status;
858
+ lastMessage = result.message;
859
+ if (isConcurrencyCap(result)) {
860
+ pool.noteConcurrencyCap(id);
861
+ cappedAccount = id;
862
+ continue;
863
+ }
864
+ if (result.kind === "session_dead") {
865
+ pool.disable(id, result.message);
866
+ continue;
867
+ }
868
+ if (result.kind === "server" || result.kind === "soft_rate") {
869
+ pool.cooldown(id, SERVER_COOLDOWN_MS, result.message);
870
+ break;
871
+ }
872
+ pool.noteError(id, 3, ERROR_COOLDOWN_MS, result.message);
873
+ break;
874
+ } finally {
875
+ pool.release(id);
876
+ }
877
+ }
878
+ if (controller.signal.aborted) return;
879
+ if (cappedAccount !== void 0) {
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;
901
+ }
902
+ } finally {
903
+ pool.release(cappedAccount);
904
+ }
905
+ }
906
+ }
907
+ if (lastKind === void 0) {
908
+ writeOpenAIError(res, 429, "soft_rate", "codearts 当前没有可用账号(全部被禁用或冷却中),请稍后重试");
909
+ return;
910
+ }
911
+ writeOpenAIError(res, KIND_STATUS[lastKind], lastKind, `codearts upstream ${lastKind} (http ${lastStatus}): ${lastMessage.slice(0, 400)}`);
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
+ }
927
+ /** 把上游 SSE 按事件边界转发给 DSH,直到流结束或被中止。 */
928
+ async function pipeToResponse(res, stream, signal, attribution, head) {
929
+ res.writeHead(200, {
930
+ "Content-Type": "text/event-stream",
931
+ "Cache-Control": "no-cache",
932
+ "Connection": "keep-alive",
933
+ "X-Accel-Buffering": "no"
934
+ });
935
+ const reader = stream.getReader();
936
+ const decoder = new TextDecoder();
937
+ let buf = "";
938
+ if (usage !== void 0 && attribution !== void 0) usage.noteRequest(attribution.accountId, attribution.model);
939
+ let sawDone = false;
940
+ const finish = () => {
941
+ if (!res.writable || res.writableEnded) return;
942
+ res.end(sawDone ? void 0 : "data: [DONE]\n\n");
943
+ };
944
+ try {
945
+ for await (const value of drainFrames(reader, head)) {
946
+ buf += decoder.decode(value, { stream: true });
947
+ let idx;
948
+ while ((idx = buf.indexOf("\n\n")) >= 0) {
949
+ const frame = buf.slice(0, idx);
950
+ buf = buf.slice(idx + 2);
951
+ if (!frame) continue;
952
+ if (frame.trim() === "data: [DONE]") sawDone = true;
953
+ if (frame.includes("\"error\"")) logInStreamError(frame, attribution?.model ?? "");
954
+ if (usage !== void 0 && attribution !== void 0 && frame.includes("\"usage\"")) recordFrameUsage(frame, attribution);
955
+ if (res.writable) res.write(frame + "\n\n");
956
+ }
957
+ }
958
+ finish();
959
+ } catch (error) {
960
+ if (!signal.aborted) logger?.warn("dsh-codearts: upstream stream failed mid-flight", error);
961
+ finish();
962
+ }
963
+ }
964
+ /** 从一帧 SSE 文本里取 usage 计入账本(解析失败就算了,不能影响转发)。 */
965
+ function recordFrameUsage(frame, attribution) {
966
+ if (usage === void 0) return;
967
+ for (const line of frame.split("\n")) {
968
+ const trimmed = line.trim();
969
+ if (!trimmed.startsWith("data:")) continue;
970
+ const payload = trimmed.slice(5).trim();
971
+ if (payload === "" || payload === "[DONE]") continue;
972
+ try {
973
+ const parsed = JSON.parse(payload);
974
+ if (parsed.usage !== void 0 && parsed.usage !== null) usage.noteUsage(attribution.accountId, attribution.model, parsed.usage);
975
+ } catch {}
976
+ }
977
+ }
978
+ return {
979
+ ready,
980
+ baseUrl,
981
+ token: () => SHARED_SECRET,
982
+ close: () => new Promise((resolve, reject) => {
983
+ server.close(() => resolve());
984
+ server.closeAllConnections();
985
+ server.once("error", reject);
986
+ })
987
+ };
988
+ }
989
+ //#endregion
990
+ //#region src/web-status.ts
991
+ /** 登录后问身份的超时:它是「顺手做对」,不能拖住登录落库。 */
992
+ const IDENTITY_TIMEOUT_MS = 8e3;
993
+ /** Route paths (same-origin under the DSH web origin). */
994
+ const CODEARTS_STATUS_PATH = "/api/codearts/status";
995
+ const CODEARTS_MODELS_PATH = "/api/codearts/models";
996
+ const CODEARTS_MODELS_SELECTION_PATH = "/api/codearts/enabled-models";
997
+ const CODEARTS_TOKEN_PATH = "/api/codearts/token";
998
+ const CODEARTS_LOGIN_PATH = "/api/codearts/login";
999
+ const CODEARTS_LOGIN_STATUS_PATH = "/api/codearts/login/status";
1000
+ /** 删除某个账号:DELETE /api/codearts/accounts?id=<accountId> */
1001
+ const CODEARTS_ACCOUNTS_PATH = "/api/codearts/accounts";
1002
+ /**
1003
+ * 由完整目录 + 已存 enabled 计算 selection。
1004
+ * - enabled 为空/未存 => 全部勾选(restricted=false)
1005
+ * - enabled 为非空列表 => 仅勾选其中的(restricted=true),缺的按未勾选
1006
+ * 福利模型用 catalog 的 benefit 标记(这里靠 model.free 字段,由调用方注入)。
1007
+ */
1008
+ function selectionOf(models, enabled, writable) {
1009
+ const allowed = new Set(enabled ?? []);
1010
+ const restricted = enabled !== void 0 && enabled.length > 0;
1011
+ return {
1012
+ choices: models.map((m) => ({
1013
+ id: m.id,
1014
+ name: m.name,
1015
+ free: m.free,
1016
+ enabled: restricted ? allowed.has(m.id) : true
1017
+ })),
1018
+ restricted,
1019
+ writable
1020
+ };
1021
+ }
1022
+ function json(res, status, body) {
1023
+ const payload = JSON.stringify(body);
1024
+ res.writeHead(status, {
1025
+ "Content-Type": "application/json",
1026
+ "Content-Length": Buffer.byteLength(payload)
1027
+ });
1028
+ res.end(payload);
1029
+ }
1030
+ function loopbackRequest(req) {
1031
+ return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
1032
+ }
1033
+ function safeMessage(error) {
1034
+ return (error instanceof Error ? error.message : String(error)).slice(0, 500);
1035
+ }
1036
+ function readBody(req, limit = 1 << 20) {
1037
+ return new Promise((resolve, reject) => {
1038
+ const chunks = [];
1039
+ let size = 0;
1040
+ req.on("data", (chunk) => {
1041
+ size += chunk.length;
1042
+ if (size > limit) {
1043
+ reject(/* @__PURE__ */ new Error("request body too large"));
1044
+ req.destroy();
1045
+ return;
1046
+ }
1047
+ chunks.push(chunk);
1048
+ });
1049
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1050
+ req.on("error", reject);
1051
+ });
1052
+ }
1053
+ /** Build the status document (no token material crosses to the browser). */
1054
+ /**
1055
+ * 登录后立刻问一次身份(有超时,失败返回 null)。
1056
+ *
1057
+ * 这一步是「尽量做对」而不是必须:拿不到身份也要能把账号存下来,只是会退回
1058
+ * 「每次登录各自成一条」的旧行为。
1059
+ */
1060
+ async function probeIdentity(token) {
1061
+ const credential = {
1062
+ token: token.securityToken,
1063
+ accessKeyId: token.accessKeyId,
1064
+ secretAccessKey: token.secretAccessKey,
1065
+ securityToken: token.securityToken
1066
+ };
1067
+ const controller = new AbortController();
1068
+ const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
1069
+ timer.unref?.();
1070
+ try {
1071
+ return await fetchCallerIdentity(credential, controller.signal);
1072
+ } catch {
1073
+ return null;
1074
+ } finally {
1075
+ clearTimeout(timer);
1076
+ }
1077
+ }
1078
+ /** 账号还能用吗(没被标 stale、也没过期)。 */
1079
+ function accountUsable(summary) {
1080
+ if (summary.stale) return false;
1081
+ if (typeof summary.expiration !== "string") return true;
1082
+ const at = Date.parse(summary.expiration);
1083
+ return !Number.isFinite(at) || at > Date.now();
1084
+ }
1085
+ /** 池状态 -> 卡片能直接渲染的槽位信息。 */
1086
+ function slotsOf(deps) {
1087
+ const runtime = deps.pool?.() ?? [];
1088
+ const now = Date.now();
1089
+ return runtime.map((account) => {
1090
+ const state = account.disabled ? "disabled" : now < account.coolUntil ? "cooling" : now < account.cappedUntil ? "capped" : "ok";
1091
+ const detail = state === "capped" ? `upstream session cap hit; avoiding it for ~${Math.ceil((account.cappedUntil - now) / 1e3)}s` : state === "cooling" ? `cooling down for ~${Math.ceil((account.coolUntil - now) / 1e3)}s` : state === "disabled" ? account.lastError ?? "disabled" : void 0;
1092
+ return {
1093
+ id: account.id,
1094
+ active: account.activeConcurrent,
1095
+ max: account.maxConcurrent,
1096
+ state,
1097
+ ...detail === void 0 ? {} : { detail }
1098
+ };
1099
+ });
1100
+ }
1101
+ /** 用量账本 -> 卡片文档;没有账本(未注入)时给一份空壳,卡片就不会瞎显示。 */
1102
+ function usageOf(deps) {
1103
+ const snapshot = deps.usage?.();
1104
+ const noTotals = {
1105
+ requests: 0,
1106
+ promptTokens: 0,
1107
+ completionTokens: 0,
1108
+ totalTokens: 0
1109
+ };
1110
+ return {
1111
+ today: snapshot?.today ?? noTotals,
1112
+ allTime: snapshot?.allTime ?? noTotals,
1113
+ byModelToday: snapshot?.byModelToday ?? [],
1114
+ days: snapshot?.days ?? 0,
1115
+ ...snapshot?.lastAt === void 0 ? {} : { lastAt: snapshot.lastAt },
1116
+ slots: slotsOf(deps)
1117
+ };
1118
+ }
1119
+ /**
1120
+ * 积分区块。
1121
+ *
1122
+ * 积分是「锦上添花」:拉不到也绝不能把状态接口本身拖垮,所以失败一律降级成
1123
+ * `creditsError` 交给卡片显示原因(超时由注入方在自己的闭包里保证)。
1124
+ */
1125
+ async function creditsOf(deps) {
1126
+ if (deps.credits === void 0) return {};
1127
+ try {
1128
+ const state = await deps.credits();
1129
+ return state.phase === "ok" ? { credits: state.value } : { creditsError: state.message };
1130
+ } catch (error) {
1131
+ return { creditsError: safeMessage(error) };
1132
+ }
1133
+ }
1134
+ async function buildStatus(deps) {
1135
+ const models = deps.models().map((m) => ({
1136
+ id: m.id,
1137
+ name: m.name
1138
+ }));
1139
+ const accounts = await deps.store.list();
1140
+ const cred = await deps.store.current();
1141
+ const usage = usageOf(deps);
1142
+ if (accounts.length === 0 || cred === void 0) return {
1143
+ status: "signed-out",
1144
+ models,
1145
+ accounts,
1146
+ usage
1147
+ };
1148
+ const usable = accounts.some(accountUsable);
1149
+ const identity = {
1150
+ ...cred.label === void 0 ? {} : { label: cred.label },
1151
+ ...cred.storedAt === void 0 ? {} : { storedAt: cred.storedAt },
1152
+ models,
1153
+ accounts,
1154
+ usage
1155
+ };
1156
+ if (!usable) return {
1157
+ status: "expired",
1158
+ ...identity
1159
+ };
1160
+ return {
1161
+ status: "signed-in",
1162
+ ...identity,
1163
+ ...await creditsOf(deps)
1164
+ };
1165
+ }
1166
+ /** GET /api/codearts/status */
1167
+ function statusHandler(deps) {
1168
+ return async (req, res) => {
1169
+ if (req.method !== "GET") {
1170
+ json(res, 405, { error: "method not allowed" });
1171
+ return;
1172
+ }
1173
+ if (!loopbackRequest(req)) {
1174
+ json(res, 403, { error: "request-not-trusted" });
1175
+ return;
1176
+ }
1177
+ try {
1178
+ json(res, 200, await buildStatus(deps));
1179
+ } catch (error) {
1180
+ json(res, 500, { error: safeMessage(error) });
1181
+ }
1182
+ };
1183
+ }
1184
+ /** GET /api/codearts/models — currently offered model ids (honours allowlist). */
1185
+ function modelsHandler(deps) {
1186
+ return async (req, res) => {
1187
+ if (req.method !== "GET") {
1188
+ json(res, 405, { error: "method not allowed" });
1189
+ return;
1190
+ }
1191
+ if (!loopbackRequest(req)) {
1192
+ json(res, 403, { error: "request-not-trusted" });
1193
+ return;
1194
+ }
1195
+ json(res, 200, { models: filterEnabledModels(deps.models(), deps.enabledModels?.()).map((m) => ({
1196
+ id: m.id,
1197
+ name: m.name
1198
+ })) });
1199
+ };
1200
+ }
1201
+ /**
1202
+ * GET/POST /api/codearts/enabled-models — 模型勾选的读写。
1203
+ * GET 返回 selection(choices + restricted + writable);POST 接收 {enabledModels}
1204
+ * 写入 settings(全选时传空数组表示"全部")。与 codebuddy 的 /plugins/<id>/enabled-models 对齐。
1205
+ */
1206
+ function modelsSelectionHandler(deps) {
1207
+ return async (req, res) => {
1208
+ if (!loopbackRequest(req)) {
1209
+ json(res, 403, { error: "request-not-trusted" });
1210
+ return;
1211
+ }
1212
+ if (req.method === "GET") {
1213
+ const all = deps.models().map((m) => ({
1214
+ id: m.id,
1215
+ name: m.name,
1216
+ free: isBenefitModel(m.id)
1217
+ }));
1218
+ const writable = deps.setEnabledModels !== void 0;
1219
+ json(res, 200, { selection: selectionOf(all, deps.enabledModels?.(), writable) });
1220
+ return;
1221
+ }
1222
+ if (req.method === "POST") {
1223
+ if (!deps.setEnabledModels) {
1224
+ json(res, 403, { error: "selection-not-writable" });
1225
+ return;
1226
+ }
1227
+ const type = req.headers["content-type"];
1228
+ if (typeof type !== "string" || !type.trim().toLowerCase().startsWith("application/json")) {
1229
+ json(res, 415, { error: "content-type must be application/json" });
1230
+ return;
1231
+ }
1232
+ try {
1233
+ const body = JSON.parse(await readBody(req));
1234
+ if (!Array.isArray(body.enabledModels) || !body.enabledModels.every((x) => typeof x === "string")) {
1235
+ json(res, 400, { error: "expected {\"enabledModels\": string[]}" });
1236
+ return;
1237
+ }
1238
+ if (!await deps.setEnabledModels(body.enabledModels)) {
1239
+ json(res, 403, { error: "selection-not-writable" });
1240
+ return;
1241
+ }
1242
+ json(res, 200, { selection: selectionOf(deps.models().map((m) => ({
1243
+ id: m.id,
1244
+ name: m.name,
1245
+ free: isBenefitModel(m.id)
1246
+ })), deps.enabledModels?.(), true) });
1247
+ } catch (error) {
1248
+ json(res, 500, { error: safeMessage(error) });
1249
+ }
1250
+ return;
1251
+ }
1252
+ json(res, 405, { error: "method not allowed" });
1253
+ };
1254
+ }
1255
+ /** POST /api/codearts/token { token, label? } — store a pasted token. */
1256
+ function tokenPostHandler(deps) {
1257
+ return async (req, res) => {
1258
+ if (req.method !== "POST") {
1259
+ json(res, 405, { error: "method not allowed" });
1260
+ return;
1261
+ }
1262
+ if (!loopbackRequest(req)) {
1263
+ json(res, 403, { error: "request-not-trusted" });
1264
+ return;
1265
+ }
1266
+ const type = req.headers["content-type"];
1267
+ if (typeof type !== "string" || !type.trim().toLowerCase().startsWith("application/json")) {
1268
+ json(res, 415, { error: "content-type must be application/json" });
1269
+ return;
1270
+ }
1271
+ try {
1272
+ const body = JSON.parse(await readBody(req));
1273
+ const token = typeof body?.token === "string" ? body.token.trim() : "";
1274
+ if (token === "") {
1275
+ json(res, 400, { error: "expected {\"token\": string}" });
1276
+ return;
1277
+ }
1278
+ const label = typeof body?.label === "string" && body.label.trim() !== "" ? body.label.trim() : void 0;
1279
+ await deps.store.set(token, label);
1280
+ json(res, 200, await buildStatus(deps));
1281
+ } catch (error) {
1282
+ json(res, 500, { error: safeMessage(error) });
1283
+ }
1284
+ };
1285
+ }
1286
+ /** DELETE /api/codearts/token — forget the stored token. */
1287
+ function tokenDeleteHandler(deps) {
1288
+ return async (req, res) => {
1289
+ if (req.method !== "DELETE") {
1290
+ json(res, 405, { error: "method not allowed" });
1291
+ return;
1292
+ }
1293
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
1294
+ json(res, 403, { error: "request-not-trusted" });
1295
+ return;
1296
+ }
1297
+ try {
1298
+ await deps.store.clear();
1299
+ json(res, 200, await buildStatus(deps));
1300
+ } catch (error) {
1301
+ json(res, 500, { error: safeMessage(error) });
1302
+ }
1303
+ };
1304
+ }
1305
+ /**
1306
+ * DELETE /api/codearts/accounts?id=<accountId> — 删掉一个账号。
1307
+ *
1308
+ * id 必须显式给:多账号下「删掉当前那个」是歧义的。改状态的路由,所以和
1309
+ * /token 一样走 Host + Origin 双校验。
1310
+ */
1311
+ function accountsHandler(deps) {
1312
+ return async (req, res) => {
1313
+ if (req.method !== "DELETE") {
1314
+ json(res, 405, { error: "method not allowed" });
1315
+ return;
1316
+ }
1317
+ if (!loopbackRequest(req)) {
1318
+ json(res, 403, { error: "request-not-trusted" });
1319
+ return;
1320
+ }
1321
+ const id = (new URL(req.url ?? "", "http://localhost").searchParams.get("id") ?? "").trim();
1322
+ if (id === "") {
1323
+ json(res, 400, { error: "expected ?id=<accountId>" });
1324
+ return;
1325
+ }
1326
+ try {
1327
+ await deps.store.remove(id);
1328
+ json(res, 200, await buildStatus(deps));
1329
+ } catch (error) {
1330
+ json(res, 500, { error: safeMessage(error) });
1331
+ }
1332
+ };
1333
+ }
1334
+ /** Mount the status / models / token routes. */
1335
+ function registerCodeArtsStatusRoute(ctx, deps) {
1336
+ ctx.effect(() => {
1337
+ const activeLogins = /* @__PURE__ */ new Map();
1338
+ /** 已结束登录的终态,供迟到的轮询读取。被读一次即删。 */
1339
+ const recentLogins = /* @__PURE__ */ new Map();
1340
+ const disposeStatus = ctx.webServer.register({
1341
+ kind: "exact",
1342
+ path: CODEARTS_STATUS_PATH,
1343
+ handler: statusHandler(deps)
1344
+ });
1345
+ const disposeModels = ctx.webServer.register({
1346
+ kind: "exact",
1347
+ path: CODEARTS_MODELS_PATH,
1348
+ handler: modelsHandler(deps)
1349
+ });
1350
+ const disposeModelsSelection = ctx.webServer.register({
1351
+ kind: "exact",
1352
+ path: CODEARTS_MODELS_SELECTION_PATH,
1353
+ handler: modelsSelectionHandler(deps)
1354
+ });
1355
+ const disposeToken = ctx.webServer.register({
1356
+ kind: "exact",
1357
+ path: CODEARTS_TOKEN_PATH,
1358
+ handler: tokenHandler(deps)
1359
+ });
1360
+ const disposeLoginStart = ctx.webServer.register({
1361
+ kind: "exact",
1362
+ path: CODEARTS_LOGIN_PATH,
1363
+ handler: loginStartHandler(deps, activeLogins, recentLogins)
1364
+ });
1365
+ const disposeLoginStatus = ctx.webServer.register({
1366
+ kind: "exact",
1367
+ path: CODEARTS_LOGIN_STATUS_PATH,
1368
+ handler: loginPollHandler(deps, activeLogins, recentLogins)
1369
+ });
1370
+ const disposeAccounts = ctx.webServer.register({
1371
+ kind: "exact",
1372
+ path: CODEARTS_ACCOUNTS_PATH,
1373
+ handler: accountsHandler(deps)
1374
+ });
1375
+ return () => {
1376
+ disposeStatus();
1377
+ disposeModels();
1378
+ disposeModelsSelection();
1379
+ disposeToken();
1380
+ disposeLoginStart();
1381
+ disposeLoginStatus();
1382
+ disposeAccounts();
1383
+ for (const s of activeLogins.values()) s.close();
1384
+ activeLogins.clear();
1385
+ };
1386
+ }, "dsh-codearts: Web status route");
1387
+ }
1388
+ /** /api/codearts/token — 一个席位按方法分派(POST 写入,DELETE 清除)。 */
1389
+ function tokenHandler(deps) {
1390
+ const post = tokenPostHandler(deps);
1391
+ const remove = tokenDeleteHandler(deps);
1392
+ return (req, res) => req.method === "DELETE" ? remove(req, res) : post(req, res);
1393
+ }
1394
+ /** 记一次登录终态;超出容量就丢最旧的,别让这个表无限长。 */
1395
+ function recordLoginOutcome(recent, url, outcome) {
1396
+ if (recent.size >= 20 && !recent.has(url)) {
1397
+ const oldest = recent.keys().next().value;
1398
+ if (oldest !== void 0) recent.delete(oldest);
1399
+ }
1400
+ recent.set(url, outcome);
1401
+ }
1402
+ /** POST /api/codearts/login — 启动本地 OAuth 回调,返回授权 URL。 */
1403
+ function loginStartHandler(deps, active, recent) {
1404
+ return async (req, res) => {
1405
+ if (req.method !== "POST") {
1406
+ json(res, 405, { error: "method not allowed" });
1407
+ return;
1408
+ }
1409
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
1410
+ json(res, 403, { error: "request-not-trusted" });
1411
+ return;
1412
+ }
1413
+ try {
1414
+ const session = await startLogin();
1415
+ active.set(session.url, session);
1416
+ session.promise.then(async (token) => {
1417
+ active.delete(session.url);
1418
+ try {
1419
+ const identity = await probeIdentity(token);
1420
+ const id = await deps.store.upsertFromOAuth(token, void 0, identity ?? void 0);
1421
+ if (identity !== null && id !== identity.userId) await deps.store.adoptIdentity(id, identity);
1422
+ recordLoginOutcome(recent, session.url, "done");
1423
+ } catch {
1424
+ recordLoginOutcome(recent, session.url, "error");
1425
+ }
1426
+ }, () => {
1427
+ active.delete(session.url);
1428
+ recordLoginOutcome(recent, session.url, "error");
1429
+ });
1430
+ const timer = setTimeout(() => {
1431
+ session.close();
1432
+ if (active.delete(session.url)) recordLoginOutcome(recent, session.url, "error");
1433
+ }, 3e5);
1434
+ session.promise.finally(() => clearTimeout(timer)).catch(() => {});
1435
+ json(res, 200, {
1436
+ url: session.url,
1437
+ port: session.port
1438
+ });
1439
+ } catch (error) {
1440
+ json(res, 500, { error: safeMessage(error) });
1441
+ }
1442
+ };
1443
+ }
1444
+ /** GET /api/codearts/login/status — 轮询登录结果。 */
1445
+ function loginPollHandler(_deps, active, recent) {
1446
+ return async (req, res) => {
1447
+ if (req.method !== "GET") {
1448
+ json(res, 405, { error: "method not allowed" });
1449
+ return;
1450
+ }
1451
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
1452
+ json(res, 403, { error: "request-not-trusted" });
1453
+ return;
1454
+ }
1455
+ const key = new URL(req.url ?? "", "http://localhost").searchParams.get("url") ?? "";
1456
+ if (active.has(key)) {
1457
+ json(res, 200, { status: "pending" });
1458
+ return;
1459
+ }
1460
+ const outcome = recent.get(key);
1461
+ if (outcome !== void 0) {
1462
+ recent.delete(key);
1463
+ json(res, 200, { status: outcome });
1464
+ return;
1465
+ }
1466
+ json(res, 200, { status: "idle" });
1467
+ };
1468
+ }
1469
+ //#endregion
1470
+ //#region src/settings-compat.ts
1471
+ const compatModule = settingsModule;
1472
+ function plainSettingsSchema(schema) {
1473
+ const clone = structuredClone(schema.toJSON());
1474
+ for (const node of Object.values(clone.refs ?? {})) if (node !== null && typeof node === "object" && node.meta !== void 0) delete node.meta.volatile;
1475
+ return z(clone);
1476
+ }
1477
+ function entryNamespaceOf(ctx) {
1478
+ const compat = ctx;
1479
+ const entry = compat.fiber?.entry;
1480
+ const candidates = [
1481
+ entry?.options?.id,
1482
+ entry?.id,
1483
+ compat.loader?.locate?.(compat.fiber)
1484
+ ];
1485
+ for (const candidate of candidates) if (typeof candidate === "string" && candidate !== "") return candidate;
1486
+ }
1487
+ function resolveSettingsNamespaceCompat(ctx, provider, fallback) {
1488
+ if (compatModule.installSettingsSection !== void 0) return fallback;
1489
+ const candidate = provider;
1490
+ if (candidate.installSection !== void 0) return fallback;
1491
+ if (candidate.configure !== void 0) return entryNamespaceOf(ctx) ?? fallback;
1492
+ return fallback;
1493
+ }
1494
+ function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
1495
+ const legacyInstaller = compatModule.installSettingsSection;
1496
+ if (legacyInstaller !== void 0) {
1497
+ legacyInstaller(ctx, ns, plainSettingsSchema(schema), entry, hooks);
1498
+ return;
1499
+ }
1500
+ ctx.inject(["settings"], (sctx) => {
1501
+ const provider = sctx.get("settings");
1502
+ if (provider === void 0) return;
1503
+ if (provider.installSection !== void 0) {
1504
+ provider.installSection(ctx, ns, plainSettingsSchema(schema), entry, hooks);
1505
+ return;
1506
+ }
1507
+ if (provider.configure === void 0) throw new TypeError("dsh-settings exposes neither installSection nor configure");
1508
+ sctx.effect(() => {
1509
+ const dispose = provider.configure?.({ auto: false }, ctx.fiber);
1510
+ return typeof dispose === "function" ? dispose : () => {};
1511
+ });
1512
+ const namespace = entryNamespaceOf(ctx);
1513
+ if (namespace !== void 0) {
1514
+ const refresh = () => {
1515
+ const descriptor = provider.describe?.().find((entry) => String(entry.ns) === namespace);
1516
+ if (descriptor?.value !== void 0) hooks.setSource?.(() => descriptor.value);
1517
+ hooks.onChange();
1518
+ };
1519
+ const settingsEvents = sctx;
1520
+ sctx.effect(() => settingsEvents.on("settings/document-updated", (updated) => {
1521
+ if (String(updated) === namespace) refresh();
1522
+ }));
1523
+ }
1524
+ });
1525
+ }
1526
+ //#endregion
1527
+ //#region src/index.ts
1528
+ /** Stable Cordis plugin name. */
1529
+ const name = "llm-codearts";
1530
+ /** The llm service is required before the provider can register. */
1531
+ const inject = ["llm"];
1532
+ /** Settings namespace owning the configuration card. */
1533
+ const CODEARTS_SETTINGS_NS = "codearts";
1534
+ function unwrapVolatileValue(value) {
1535
+ if (typeof value === "object" && value !== null && typeof value.get === "function") return value.get();
1536
+ return value;
1537
+ }
1538
+ function unwrapConfig(value) {
1539
+ if (typeof value !== "object" || value === null) return {};
1540
+ const out = {};
1541
+ for (const [key, field] of Object.entries(value)) out[key] = unwrapVolatileValue(field);
1542
+ return out;
1543
+ }
1544
+ const Config = z.object({ enabledModels: z.array(z.string()).description("Model ids offered in the model pickers (empty means every model)").volatile() });
1545
+ function settingsCompatNamespace(ctx) {
1546
+ return resolveSettingsNamespaceCompat(ctx, ctx.get("settings"), CODEARTS_SETTINGS_NS);
1547
+ }
1548
+ function apply(ctx, rawConfig) {
1549
+ const config = unwrapConfig(rawConfig);
1550
+ const accountsPath = codeartsAccountsPath();
1551
+ const store = new CodeArtsAccountStore(accountsPath);
1552
+ const pool = new CodeArtsAccountPool([]);
1553
+ const catalog = new CodeArtsCatalog();
1554
+ const usage = new CodeArtsUsageTracker({ path: codeartsUsagePath() });
1555
+ const credits = new CodeArtsCreditCache();
1556
+ /** /status 是卡片与对话底部都会轮询的接口,积分慢请求不能把它拖成转圈。 */
1557
+ const CREDIT_TIMEOUT_MS = 8e3;
1558
+ /**
1559
+ * 拉一次积分。
1560
+ *
1561
+ * 积分是**账号级**属性,不随请求轮转:这里挑第一个没用坏的账号,够覆盖正常用法
1562
+ * (多账号下的积分差异不在本插件关心范围内)。
1563
+ */
1564
+ const loadCredits = async (signal) => {
1565
+ const ids = await store.ids();
1566
+ if (ids.length === 0) return {
1567
+ phase: "error",
1568
+ message: "not signed in",
1569
+ at: Date.now()
1570
+ };
1571
+ const controller = new AbortController();
1572
+ const timer = setTimeout(() => controller.abort(), CREDIT_TIMEOUT_MS);
1573
+ timer.unref?.();
1574
+ signal?.addEventListener("abort", () => controller.abort(), { once: true });
1575
+ try {
1576
+ let last;
1577
+ for (const id of ids) {
1578
+ const stored = await store.credential(id);
1579
+ if (stored === void 0 || stored.stale === true) continue;
1580
+ const resolved = await store.resolve(id);
1581
+ if (resolved === void 0) continue;
1582
+ const state = await credits.get(resolved, controller.signal);
1583
+ if (state.phase === "ok") return state;
1584
+ last = state;
1585
+ }
1586
+ return last ?? {
1587
+ phase: "error",
1588
+ message: "no usable credential",
1589
+ at: Date.now()
1590
+ };
1591
+ } finally {
1592
+ clearTimeout(timer);
1593
+ }
1594
+ };
1595
+ let current = () => config;
1596
+ const enabledModels = () => current().enabledModels;
1597
+ const setEnabledModels = async (ids) => {
1598
+ const settings = ctx.get("settings");
1599
+ if (settings === void 0) return false;
1600
+ await settings.update(settingsCompatNamespace(ctx), { enabledModels: [...ids] });
1601
+ return true;
1602
+ };
1603
+ ctx.inject(["webServer"], (webCtx) => registerCodeArtsStatusRoute(webCtx, {
1604
+ store,
1605
+ models: () => catalog.current(),
1606
+ enabledModels,
1607
+ setEnabledModels,
1608
+ pool: () => pool.list(),
1609
+ usage: () => usage.snapshot(),
1610
+ credits: (signal) => loadCredits(signal)
1611
+ }));
1612
+ installSettingsSectionCompat(ctx, CODEARTS_SETTINGS_NS, Config, config, {
1613
+ setSource(source) {
1614
+ current = () => unwrapConfig(source());
1615
+ },
1616
+ onChange() {}
1617
+ });
1618
+ let stopped = false;
1619
+ ctx.effect(() => () => {
1620
+ stopped = true;
1621
+ usage.flush().catch(() => {});
1622
+ shim.close().catch(() => {});
1623
+ });
1624
+ const CATALOG_REFRESH_MS = 36e5;
1625
+ const abort = new AbortController();
1626
+ const discover = async () => {
1627
+ if (stopped) return;
1628
+ try {
1629
+ const outcome = await refreshCatalog(store, catalog, abort.signal);
1630
+ if (outcome === void 0 || stopped) return;
1631
+ if (outcome.anySucceeded) {
1632
+ const free = outcome.models.filter((model) => model.benefit === true).length;
1633
+ ctx.logger.debug(`dsh-codearts: model discovery refreshed the catalog (${outcome.models.length} models, ${free} benefit)`);
1634
+ } else ctx.logger.warn("dsh-codearts: model discovery found nothing usable; keeping the current catalog", outcome.accounts.flatMap((account) => account.errors));
1635
+ } catch (error) {
1636
+ ctx.logger.debug("dsh-codearts: model discovery failed; keeping the current catalog", error);
1637
+ }
1638
+ };
1639
+ ctx.effect(() => {
1640
+ const timer = setInterval(() => void discover(), CATALOG_REFRESH_MS);
1641
+ timer.unref?.();
1642
+ return () => {
1643
+ abort.abort();
1644
+ clearInterval(timer);
1645
+ };
1646
+ });
1647
+ const shim = createCodeArtsShim({
1648
+ store,
1649
+ catalog,
1650
+ pool,
1651
+ usage,
1652
+ logger: ctx.logger
1653
+ });
1654
+ usage.load().catch(() => {});
1655
+ shim.ready.then(() => {
1656
+ if (stopped) return;
1657
+ discover();
1658
+ const codearts = createCodeArtsAdapter({
1659
+ shim,
1660
+ catalog,
1661
+ enabledModels,
1662
+ resolveAttachments: () => ctx.get("attachments")
1663
+ });
1664
+ let releaseAdapter;
1665
+ let releaseDirectory;
1666
+ try {
1667
+ releaseAdapter = ctx.llm.registerAdapter([CODEARTS_PROVIDER], codearts.adapter);
1668
+ releaseDirectory = ctx.llm.registerConfigurableProviders([{
1669
+ provider: CODEARTS_PROVIDER,
1670
+ displayName: "CodeArts",
1671
+ settingsNs: settingsCompatNamespace(ctx),
1672
+ settingsPath: [],
1673
+ declared: false
1674
+ }]);
1675
+ } finally {
1676
+ if (releaseAdapter === void 0 || releaseDirectory === void 0) {
1677
+ releaseAdapter?.();
1678
+ releaseDirectory?.();
1679
+ }
1680
+ }
1681
+ try {
1682
+ ctx.effect(() => () => {
1683
+ releaseAdapter?.();
1684
+ releaseDirectory?.();
1685
+ });
1686
+ } catch {
1687
+ releaseAdapter?.();
1688
+ releaseDirectory?.();
1689
+ }
1690
+ }).catch((error) => {
1691
+ ctx.logger.error("dsh-codearts: loopback shim failed to start; provider not registered", error);
1692
+ });
1693
+ }
1694
+ //#endregion
1695
+ 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 };