dsh-codearts 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV.md +97 -0
- package/INSTALL.en.md +135 -0
- package/INSTALL.md +115 -0
- package/LICENSE +21 -0
- package/README.en.md +157 -0
- package/README.md +123 -2
- package/cordis.patch.yml +6 -0
- package/lib/bin.d.mts +1 -0
- package/lib/bin.mjs +373 -0
- package/lib/client/index.d.cts +86 -0
- package/lib/client/index.js +789 -0
- package/lib/index.d.mts +571 -0
- package/lib/index.mjs +1320 -0
- package/lib/usage-ClRZWUS4.mjs +2137 -0
- package/package.json +122 -4
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1320 @@
|
|
|
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";
|
|
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/adapter.ts
|
|
140
|
+
/**
|
|
141
|
+
* The `codearts` pi-ai provider.
|
|
142
|
+
*
|
|
143
|
+
* One loopback-backed adapter registered into the Harness LLM seam, assembled
|
|
144
|
+
* from the same public `dsh-llm-pi-ai` extension points dsh-codebuddy-cli uses.
|
|
145
|
+
* The shim does the CodeArts wire translation; this file only describes the
|
|
146
|
+
* models and points pi-ai at the shim's OpenAI-compatible endpoint.
|
|
147
|
+
*
|
|
148
|
+
* MVP simplifications vs dsh-codebuddy-cli:
|
|
149
|
+
* - no billing rate in the model name (CodeArts has no balance API)
|
|
150
|
+
* - no per-model reasoning ladder (models are surfaced as plain chat models)
|
|
151
|
+
*
|
|
152
|
+
* @module dsh-codearts/adapter
|
|
153
|
+
*/
|
|
154
|
+
/** Provider route this bundle owns. Keep in sync with cordis.patch.yml. */
|
|
155
|
+
const CODEARTS_PROVIDER = "codearts";
|
|
156
|
+
/**
|
|
157
|
+
* Provider idle ceiling while one stream read is outstanding.
|
|
158
|
+
*
|
|
159
|
+
* 90s 而不是常见的 300s:华为免费推理后端实测会流中途停摆(吐几个 token 后
|
|
160
|
+
* 长时间静默),300s 意味着用户要在"等待"里干等 5 分钟才进入自动重试。
|
|
161
|
+
* 90s 足够容忍首 token 冷启动与推理间隙,又能让 DSH 的重试策略
|
|
162
|
+
* (maxRetries=5, TIMEOUT 可重试)更快接管。
|
|
163
|
+
*/
|
|
164
|
+
const CODEARTS_STREAM_IDLE_TIMEOUT_MS = 9e4;
|
|
165
|
+
/** No per-token pricing is knowable for a subscription quota; report zero. */
|
|
166
|
+
const NO_COST = {
|
|
167
|
+
input: 0,
|
|
168
|
+
output: 0,
|
|
169
|
+
cacheRead: 0,
|
|
170
|
+
cacheWrite: 0
|
|
171
|
+
};
|
|
172
|
+
const REQUEST_IMAGE_BUDGETS = {
|
|
173
|
+
maxRequestImageBytes: 20971520,
|
|
174
|
+
requestImagePixelBudget: 4194304,
|
|
175
|
+
requestImageMaxBytes: 1048576
|
|
176
|
+
};
|
|
177
|
+
/** Inert pi-ai auth plane: the codearts route authenticates only via the shim secret. */
|
|
178
|
+
const INERT_AUTH = {
|
|
179
|
+
credentials: {
|
|
180
|
+
async read() {},
|
|
181
|
+
async list() {
|
|
182
|
+
return [];
|
|
183
|
+
},
|
|
184
|
+
async modify() {
|
|
185
|
+
throw new Error("dsh-codearts: the codearts route has no pi-ai credential lifecycle");
|
|
186
|
+
},
|
|
187
|
+
async delete() {}
|
|
188
|
+
},
|
|
189
|
+
authContext: {
|
|
190
|
+
async env() {},
|
|
191
|
+
async fileExists() {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
197
|
+
function toPiModel(info, baseUrl) {
|
|
198
|
+
return {
|
|
199
|
+
id: info.id,
|
|
200
|
+
name: info.name,
|
|
201
|
+
api: "openai-completions",
|
|
202
|
+
provider: CODEARTS_PROVIDER,
|
|
203
|
+
baseUrl,
|
|
204
|
+
input: info.supportsImages === true ? ["text", "image"] : ["text"],
|
|
205
|
+
reasoning: false,
|
|
206
|
+
cost: NO_COST,
|
|
207
|
+
contextWindow: info.contextWindow,
|
|
208
|
+
maxTokens: info.maxTokens
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function createCodeArtsAdapter(options) {
|
|
212
|
+
const { shim, catalog, enabledModels, resolveAttachments } = options;
|
|
213
|
+
const buildModels = () => {
|
|
214
|
+
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
215
|
+
return catalog.current().map((info) => toPiModel(info, baseUrl));
|
|
216
|
+
};
|
|
217
|
+
const provider = {
|
|
218
|
+
...createProvider({
|
|
219
|
+
id: CODEARTS_PROVIDER,
|
|
220
|
+
name: "CodeArts",
|
|
221
|
+
auth: { apiKey: {
|
|
222
|
+
name: "CodeArts token",
|
|
223
|
+
async resolve({ credential }) {
|
|
224
|
+
const apiKey = credential?.key;
|
|
225
|
+
return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
|
|
226
|
+
auth: { apiKey },
|
|
227
|
+
source: "CodeArts"
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
} },
|
|
231
|
+
models: buildModels(),
|
|
232
|
+
api: openAICompletionsApi()
|
|
233
|
+
}),
|
|
234
|
+
getModels: () => buildModels()
|
|
235
|
+
};
|
|
236
|
+
const profile = {
|
|
237
|
+
provider: CODEARTS_PROVIDER,
|
|
238
|
+
displayName: "CodeArts",
|
|
239
|
+
streamIdleTimeoutMs: CODEARTS_STREAM_IDLE_TIMEOUT_MS,
|
|
240
|
+
retryPolicy: resolveRetryPolicy(void 0, "dsh-codearts retryPolicy"),
|
|
241
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
242
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
243
|
+
...REQUEST_IMAGE_BUDGETS,
|
|
244
|
+
piProvider: provider
|
|
245
|
+
};
|
|
246
|
+
let profiles = /* @__PURE__ */ new Map([[CODEARTS_PROVIDER, profile]]);
|
|
247
|
+
return {
|
|
248
|
+
adapter: new CodeArtsPiAiAdapter(catalog, enabledModels, {
|
|
249
|
+
profiles: () => profiles,
|
|
250
|
+
auth: INERT_AUTH,
|
|
251
|
+
resolveApiKey: async () => shim.token(),
|
|
252
|
+
...resolveAttachments === void 0 ? {} : { resolveAttachments }
|
|
253
|
+
}),
|
|
254
|
+
invalidate: () => {
|
|
255
|
+
profiles = /* @__PURE__ */ new Map([[CODEARTS_PROVIDER, profile]]);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Adapter that narrows the *offered* model surface to the enabled allowlist
|
|
261
|
+
* (dispatch stays whole). Mirrors dsh-codebuddy-cli's CodeBuddyPiAiAdapter.
|
|
262
|
+
*/
|
|
263
|
+
var CodeArtsPiAiAdapter = class extends PiAiAdapter {
|
|
264
|
+
catalog;
|
|
265
|
+
enabledModels;
|
|
266
|
+
constructor(catalog, enabledModels, options) {
|
|
267
|
+
super(options);
|
|
268
|
+
this.catalog = catalog;
|
|
269
|
+
this.enabledModels = enabledModels;
|
|
270
|
+
}
|
|
271
|
+
async listModels(provider) {
|
|
272
|
+
const models = await super.listModels(provider);
|
|
273
|
+
const allowed = filterEnabledModels(this.catalog.current(), this.enabledModels?.());
|
|
274
|
+
const catalogIds = new Set(this.catalog.current().map((entry) => entry.id));
|
|
275
|
+
const allowedIds = new Set(allowed.map((entry) => entry.id));
|
|
276
|
+
return models.filter((model) => !catalogIds.has(model.id) || allowedIds.has(model.id));
|
|
277
|
+
}
|
|
278
|
+
async resolveModel(provider, model, signal) {
|
|
279
|
+
return super.resolveModel(provider, model, signal);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/loopback.ts
|
|
284
|
+
/**
|
|
285
|
+
* Shared loopback gates for the plugin's local HTTP surfaces: the loopback
|
|
286
|
+
* shim and the same-origin web-status route. Both are only ever meant to be
|
|
287
|
+
* addressed through the machine's loopback interface.
|
|
288
|
+
*
|
|
289
|
+
* The 127.0.0.1 bind alone is not a trust boundary (any local process or a
|
|
290
|
+
* DNS-rebinding page can reach 127.0.0.1), so both surfaces also check the
|
|
291
|
+
* Host and Origin headers.
|
|
292
|
+
*
|
|
293
|
+
* @module dsh-codearts/loopback
|
|
294
|
+
*/
|
|
295
|
+
/**
|
|
296
|
+
* Loopback hostnames a local plugin surface may be addressed by.
|
|
297
|
+
*
|
|
298
|
+
* `0.0.0.0` and `::` are deliberately absent: they are wildcard *bind*
|
|
299
|
+
* addresses, not loopback identities, and accepting them in a Host header
|
|
300
|
+
* would let a request that never went through the loopback interface pass.
|
|
301
|
+
*/
|
|
302
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
303
|
+
"127.0.0.1",
|
|
304
|
+
"localhost",
|
|
305
|
+
"[::1]"
|
|
306
|
+
]);
|
|
307
|
+
/** Strip the optional :port from a Host header value, IPv6-bracket aware. */
|
|
308
|
+
function hostnameOfHost(host) {
|
|
309
|
+
let hostname = host.trim().toLowerCase();
|
|
310
|
+
if (hostname.startsWith("[")) {
|
|
311
|
+
const end = hostname.indexOf("]");
|
|
312
|
+
return end === -1 ? hostname : hostname.slice(0, end + 1);
|
|
313
|
+
}
|
|
314
|
+
const colon = hostname.lastIndexOf(":");
|
|
315
|
+
if (colon !== -1 && !hostname.slice(0, colon).includes(":") && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
|
|
316
|
+
return hostname;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The request's Host header must name the loopback interface. A DNS-rebinding
|
|
320
|
+
* page (attacker domain re-resolved to 127.0.0.1) sends its own domain in
|
|
321
|
+
* Host, so this check drops those before any routing happens.
|
|
322
|
+
*/
|
|
323
|
+
function hostIsLoopback(host) {
|
|
324
|
+
if (host === void 0 || host.trim() === "") return false;
|
|
325
|
+
return LOOPBACK_HOSTS.has(hostnameOfHost(host));
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A browser-sent Origin (present header) must be loopback. Non-browser
|
|
329
|
+
* clients (the plugin's own fetch calls) send no Origin at all and pass.
|
|
330
|
+
*/
|
|
331
|
+
function originIsLoopback(origin) {
|
|
332
|
+
if (origin === void 0 || origin.trim() === "") return true;
|
|
333
|
+
try {
|
|
334
|
+
const { hostname } = new URL(origin);
|
|
335
|
+
return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/shim.ts
|
|
342
|
+
/**
|
|
343
|
+
* Loopback OpenAI-compatible endpoint for CodeArts.
|
|
344
|
+
*
|
|
345
|
+
* DSH's pi-ai provider points here. The shim validates the loopback + shared
|
|
346
|
+
* secret, resolves the pasted `cloud_dragon_token`, and forwards the request
|
|
347
|
+
* to CodeArts' `/api/v2/chat/completions` — whose SSE chunk shape is already
|
|
348
|
+
* OpenAI-compatible, so we pipe the upstream stream straight through.
|
|
349
|
+
*
|
|
350
|
+
* Inbound hardening (copied from dsh-codebuddy-cli): bind 127.0.0.1 only,
|
|
351
|
+
* require a loopback Host header, a loopback Origin, and the per-process
|
|
352
|
+
* shared secret as `Authorization: Bearer`. Any local attacker hitting the
|
|
353
|
+
* port still cannot forge the secret.
|
|
354
|
+
*
|
|
355
|
+
* @module dsh-codearts/shim
|
|
356
|
+
*/
|
|
357
|
+
const REQUEST_BODY_LIMIT = 67108864;
|
|
358
|
+
const KIND_STATUS = {
|
|
359
|
+
hard_credit: 402,
|
|
360
|
+
soft_rate: 429,
|
|
361
|
+
session_dead: 401,
|
|
362
|
+
not_found: 502,
|
|
363
|
+
server: 502,
|
|
364
|
+
client: 400
|
|
365
|
+
};
|
|
366
|
+
function isJsonContentType(req) {
|
|
367
|
+
const type = req.headers["content-type"];
|
|
368
|
+
return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
|
|
369
|
+
}
|
|
370
|
+
function writeJson(res, status, body) {
|
|
371
|
+
const payload = JSON.stringify(body);
|
|
372
|
+
res.writeHead(status, {
|
|
373
|
+
"Content-Type": "application/json",
|
|
374
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
375
|
+
});
|
|
376
|
+
res.end(payload);
|
|
377
|
+
}
|
|
378
|
+
function writeOpenAIError(res, status, kind, message) {
|
|
379
|
+
writeJson(res, status, { error: {
|
|
380
|
+
message,
|
|
381
|
+
type: kind,
|
|
382
|
+
code: kind
|
|
383
|
+
} });
|
|
384
|
+
}
|
|
385
|
+
function readBody$1(req) {
|
|
386
|
+
return new Promise((resolve, reject) => {
|
|
387
|
+
const chunks = [];
|
|
388
|
+
let size = 0;
|
|
389
|
+
req.on("data", (chunk) => {
|
|
390
|
+
size += chunk.length;
|
|
391
|
+
if (size > REQUEST_BODY_LIMIT) {
|
|
392
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
393
|
+
req.destroy();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
chunks.push(chunk);
|
|
397
|
+
});
|
|
398
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
399
|
+
req.on("error", reject);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/** 一次请求最多试几个账号(与 Go 参考的 MaxRotate 一致)。 */
|
|
403
|
+
const MAX_ROTATE = 3;
|
|
404
|
+
/**
|
|
405
|
+
* 所有账号都撞上并发上限时,回落到「在同一账号上原地重发」的预算。
|
|
406
|
+
*
|
|
407
|
+
* 2026-09-30 实测:一个上游会话要 ~52s 才被释放,而且**中途打断并不减免** ——
|
|
408
|
+
* 只跑了 98ms 就中止的请求,同样占满一个名额 52s。所以用户「打断 → 马上重问」
|
|
409
|
+
* 时等的就是这个 52s。池里有别的账号时下面会直接换号(不用等);这条是最后一根
|
|
410
|
+
* 稻草,必须盖过 52s,否则用户看到的不是「稍等一下」而是直接失败。
|
|
411
|
+
*
|
|
412
|
+
* 上限受 pi-ai 的 90s idle 超时约束:等待期间我们发不出任何内容,超过 90s 用户
|
|
413
|
+
* 看到的就是超时。75s 给 52s 留了余量,也还在 90s 之内。
|
|
414
|
+
*/
|
|
415
|
+
const CAP_WAIT_LIMIT = 75e3;
|
|
416
|
+
const CAP_RETRY_GAP = 3e3;
|
|
417
|
+
/** 上游 5xx 后短时冷却该账号。 */
|
|
418
|
+
const SERVER_COOLDOWN_MS = 3e4;
|
|
419
|
+
/** 零散错误累计到阈值后冷却该账号。 */
|
|
420
|
+
const ERROR_COOLDOWN_MS = 6e4;
|
|
421
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
422
|
+
/**
|
|
423
|
+
* 在**同一个账号**上重发,直到不再撞上限或预算耗尽。
|
|
424
|
+
*
|
|
425
|
+
* 只在没有别的账号可选时使用 —— 等 52s 是最后手段,能换号就换号。
|
|
426
|
+
* 命中上限不甩给 pi-ai:429 会被重试 5 次,每次再去要一个会话,越试越满。
|
|
427
|
+
*/
|
|
428
|
+
async function openStream(credential, prepared, signal) {
|
|
429
|
+
const until = Date.now() + CAP_WAIT_LIMIT;
|
|
430
|
+
for (;;) {
|
|
431
|
+
const result = await chatStream(credential, prepared, signal);
|
|
432
|
+
if (result.ok) return result;
|
|
433
|
+
if (!(isConcurrencyCap(result) && !signal.aborted && Date.now() + CAP_RETRY_GAP <= until)) return result;
|
|
434
|
+
await sleep(CAP_RETRY_GAP);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function createCodeArtsShim(options) {
|
|
438
|
+
const { store, catalog } = options;
|
|
439
|
+
const logger = options.logger;
|
|
440
|
+
const usage = options.usage;
|
|
441
|
+
const pool = options.pool ?? new CodeArtsAccountPool([]);
|
|
442
|
+
const SHARED_SECRET = randomBytes(32).toString("base64url");
|
|
443
|
+
function bearerOk(req) {
|
|
444
|
+
const header = req.headers.authorization;
|
|
445
|
+
if (typeof header !== "string") return false;
|
|
446
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
447
|
+
if (match === null) return false;
|
|
448
|
+
const presented = match[1];
|
|
449
|
+
const a = Buffer.from(presented);
|
|
450
|
+
const b = Buffer.from(SHARED_SECRET);
|
|
451
|
+
if (a.length !== b.length) return false;
|
|
452
|
+
return timingSafeEqual(a, b);
|
|
453
|
+
}
|
|
454
|
+
const server = createServer((req, res) => {
|
|
455
|
+
handle(req, res);
|
|
456
|
+
});
|
|
457
|
+
const ready = new Promise((resolve, reject) => {
|
|
458
|
+
server.once("listening", () => resolve());
|
|
459
|
+
server.once("error", reject);
|
|
460
|
+
});
|
|
461
|
+
server.listen(0, "127.0.0.1");
|
|
462
|
+
const baseUrl = () => {
|
|
463
|
+
const address = server.address();
|
|
464
|
+
if (address === null || typeof address === "string") throw new Error("codearts shim has no listening address");
|
|
465
|
+
return `http://127.0.0.1:${address.port}`;
|
|
466
|
+
};
|
|
467
|
+
async function handle(req, res) {
|
|
468
|
+
try {
|
|
469
|
+
if (!hostIsLoopback(req.headers.host)) {
|
|
470
|
+
writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!originIsLoopback(req.headers.origin)) {
|
|
474
|
+
writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (!bearerOk(req)) {
|
|
478
|
+
writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const url = req.url ?? "/";
|
|
482
|
+
if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
|
|
483
|
+
writeJson(res, 200, { ok: true });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
|
|
487
|
+
writeJson(res, 200, {
|
|
488
|
+
object: "list",
|
|
489
|
+
data: catalog.current().map((model) => ({
|
|
490
|
+
id: model.id,
|
|
491
|
+
object: "model",
|
|
492
|
+
created: 0,
|
|
493
|
+
owned_by: "codearts"
|
|
494
|
+
}))
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
|
|
499
|
+
await chatCompletions(req, res);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
|
|
505
|
+
else res.end();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function chatCompletions(req, res) {
|
|
509
|
+
if (!isJsonContentType(req)) {
|
|
510
|
+
writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const raw = (await readBody$1(req)).toString("utf8");
|
|
514
|
+
const prepared = prepareChatBody(raw);
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
res.on("close", () => {
|
|
517
|
+
if (!res.writableEnded) controller.abort();
|
|
518
|
+
});
|
|
519
|
+
let ids;
|
|
520
|
+
try {
|
|
521
|
+
ids = await store.ids();
|
|
522
|
+
} catch (error) {
|
|
523
|
+
writeOpenAIError(res, 401, "not_signed_in", String(error));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (ids.length === 0) {
|
|
527
|
+
writeOpenAIError(res, 401, "not_signed_in", "no CodeArts account stored; sign in from the CodeArts settings card");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
pool.sync(ids);
|
|
531
|
+
const tried = /* @__PURE__ */ new Set();
|
|
532
|
+
let cappedAccount;
|
|
533
|
+
let lastKind;
|
|
534
|
+
let lastStatus = 0;
|
|
535
|
+
let lastMessage = "";
|
|
536
|
+
for (let attempt = 0; attempt < MAX_ROTATE; attempt += 1) {
|
|
537
|
+
const id = pool.pick(tried);
|
|
538
|
+
if (id === void 0) break;
|
|
539
|
+
tried.add(id);
|
|
540
|
+
if (!pool.acquire(id)) continue;
|
|
541
|
+
try {
|
|
542
|
+
const credential = await store.resolve(id);
|
|
543
|
+
if (credential === void 0) {
|
|
544
|
+
pool.disable(id, "no credential");
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const result = await chatStream(credential, prepared, controller.signal);
|
|
548
|
+
if (controller.signal.aborted) {
|
|
549
|
+
if (result.ok) await result.stream.cancel().catch(() => {});
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (result.ok) {
|
|
553
|
+
pool.noteSuccess(id);
|
|
554
|
+
await pipeToResponse(res, result.stream, controller.signal, {
|
|
555
|
+
accountId: id,
|
|
556
|
+
model: prepared.model
|
|
557
|
+
});
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
lastKind = result.kind;
|
|
561
|
+
lastStatus = result.status;
|
|
562
|
+
lastMessage = result.message;
|
|
563
|
+
if (isConcurrencyCap(result)) {
|
|
564
|
+
pool.noteConcurrencyCap(id);
|
|
565
|
+
cappedAccount = id;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (result.kind === "session_dead") {
|
|
569
|
+
pool.disable(id, result.message);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (result.kind === "server" || result.kind === "soft_rate") {
|
|
573
|
+
pool.cooldown(id, SERVER_COOLDOWN_MS, result.message);
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
pool.noteError(id, 3, ERROR_COOLDOWN_MS, result.message);
|
|
577
|
+
break;
|
|
578
|
+
} finally {
|
|
579
|
+
pool.release(id);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (controller.signal.aborted) return;
|
|
583
|
+
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;
|
|
599
|
+
}
|
|
600
|
+
lastKind = result.kind;
|
|
601
|
+
lastStatus = result.status;
|
|
602
|
+
lastMessage = result.message;
|
|
603
|
+
}
|
|
604
|
+
} finally {
|
|
605
|
+
pool.release(cappedAccount);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (lastKind === void 0) {
|
|
609
|
+
writeOpenAIError(res, 429, "soft_rate", "codearts 当前没有可用账号(全部被禁用或冷却中),请稍后重试");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
writeOpenAIError(res, KIND_STATUS[lastKind], lastKind, `codearts upstream ${lastKind} (http ${lastStatus}): ${lastMessage.slice(0, 400)}`);
|
|
613
|
+
}
|
|
614
|
+
/** 把上游 SSE 按事件边界转发给 DSH,直到流结束或被中止。 */
|
|
615
|
+
async function pipeToResponse(res, stream, signal, attribution) {
|
|
616
|
+
res.writeHead(200, {
|
|
617
|
+
"Content-Type": "text/event-stream",
|
|
618
|
+
"Cache-Control": "no-cache",
|
|
619
|
+
"Connection": "keep-alive",
|
|
620
|
+
"X-Accel-Buffering": "no"
|
|
621
|
+
});
|
|
622
|
+
const reader = stream.getReader();
|
|
623
|
+
const decoder = new TextDecoder();
|
|
624
|
+
let buf = "";
|
|
625
|
+
if (usage !== void 0 && attribution !== void 0) usage.noteRequest(attribution.accountId, attribution.model);
|
|
626
|
+
let sawDone = false;
|
|
627
|
+
const finish = () => {
|
|
628
|
+
if (!res.writable || res.writableEnded) return;
|
|
629
|
+
res.end(sawDone ? void 0 : "data: [DONE]\n\n");
|
|
630
|
+
};
|
|
631
|
+
try {
|
|
632
|
+
for (;;) {
|
|
633
|
+
const { done, value } = await reader.read();
|
|
634
|
+
if (done) {
|
|
635
|
+
finish();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
buf += decoder.decode(value, { stream: true });
|
|
639
|
+
let idx;
|
|
640
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
641
|
+
const frame = buf.slice(0, idx);
|
|
642
|
+
buf = buf.slice(idx + 2);
|
|
643
|
+
if (!frame) continue;
|
|
644
|
+
if (frame.trim() === "data: [DONE]") sawDone = true;
|
|
645
|
+
if (usage !== void 0 && attribution !== void 0 && frame.includes("\"usage\"")) recordFrameUsage(frame, attribution);
|
|
646
|
+
if (res.writable) res.write(frame + "\n\n");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (!signal.aborted) logger?.warn("dsh-codearts: upstream stream failed mid-flight", error);
|
|
651
|
+
finish();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** 从一帧 SSE 文本里取 usage 计入账本(解析失败就算了,不能影响转发)。 */
|
|
655
|
+
function recordFrameUsage(frame, attribution) {
|
|
656
|
+
if (usage === void 0) return;
|
|
657
|
+
for (const line of frame.split("\n")) {
|
|
658
|
+
const trimmed = line.trim();
|
|
659
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
660
|
+
const payload = trimmed.slice(5).trim();
|
|
661
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
662
|
+
try {
|
|
663
|
+
const parsed = JSON.parse(payload);
|
|
664
|
+
if (parsed.usage !== void 0 && parsed.usage !== null) usage.noteUsage(attribution.accountId, attribution.model, parsed.usage);
|
|
665
|
+
} catch {}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
ready,
|
|
670
|
+
baseUrl,
|
|
671
|
+
token: () => SHARED_SECRET,
|
|
672
|
+
close: () => new Promise((resolve, reject) => {
|
|
673
|
+
server.close(() => resolve());
|
|
674
|
+
server.closeAllConnections();
|
|
675
|
+
server.once("error", reject);
|
|
676
|
+
})
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
//#region src/web-status.ts
|
|
681
|
+
/** 登录后问身份的超时:它是「顺手做对」,不能拖住登录落库。 */
|
|
682
|
+
const IDENTITY_TIMEOUT_MS = 8e3;
|
|
683
|
+
/** Route paths (same-origin under the DSH web origin). */
|
|
684
|
+
const CODEARTS_STATUS_PATH = "/api/codearts/status";
|
|
685
|
+
const CODEARTS_MODELS_PATH = "/api/codearts/models";
|
|
686
|
+
const CODEARTS_MODELS_SELECTION_PATH = "/api/codearts/enabled-models";
|
|
687
|
+
const CODEARTS_TOKEN_PATH = "/api/codearts/token";
|
|
688
|
+
const CODEARTS_LOGIN_PATH = "/api/codearts/login";
|
|
689
|
+
const CODEARTS_LOGIN_STATUS_PATH = "/api/codearts/login/status";
|
|
690
|
+
/** 删除某个账号:DELETE /api/codearts/accounts?id=<accountId> */
|
|
691
|
+
const CODEARTS_ACCOUNTS_PATH = "/api/codearts/accounts";
|
|
692
|
+
/**
|
|
693
|
+
* 由完整目录 + 已存 enabled 计算 selection。
|
|
694
|
+
* - enabled 为空/未存 => 全部勾选(restricted=false)
|
|
695
|
+
* - enabled 为非空列表 => 仅勾选其中的(restricted=true),缺的按未勾选
|
|
696
|
+
* 福利模型用 catalog 的 benefit 标记(这里靠 model.free 字段,由调用方注入)。
|
|
697
|
+
*/
|
|
698
|
+
function selectionOf(models, enabled, writable) {
|
|
699
|
+
const allowed = new Set(enabled ?? []);
|
|
700
|
+
const restricted = enabled !== void 0 && enabled.length > 0;
|
|
701
|
+
return {
|
|
702
|
+
choices: models.map((m) => ({
|
|
703
|
+
id: m.id,
|
|
704
|
+
name: m.name,
|
|
705
|
+
free: m.free,
|
|
706
|
+
enabled: restricted ? allowed.has(m.id) : true
|
|
707
|
+
})),
|
|
708
|
+
restricted,
|
|
709
|
+
writable
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function json(res, status, body) {
|
|
713
|
+
const payload = JSON.stringify(body);
|
|
714
|
+
res.writeHead(status, {
|
|
715
|
+
"Content-Type": "application/json",
|
|
716
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
717
|
+
});
|
|
718
|
+
res.end(payload);
|
|
719
|
+
}
|
|
720
|
+
function loopbackRequest(req) {
|
|
721
|
+
return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
|
|
722
|
+
}
|
|
723
|
+
function safeMessage(error) {
|
|
724
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
725
|
+
}
|
|
726
|
+
function readBody(req, limit = 1 << 20) {
|
|
727
|
+
return new Promise((resolve, reject) => {
|
|
728
|
+
const chunks = [];
|
|
729
|
+
let size = 0;
|
|
730
|
+
req.on("data", (chunk) => {
|
|
731
|
+
size += chunk.length;
|
|
732
|
+
if (size > limit) {
|
|
733
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
734
|
+
req.destroy();
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
chunks.push(chunk);
|
|
738
|
+
});
|
|
739
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
740
|
+
req.on("error", reject);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
/** Build the status document (no token material crosses to the browser). */
|
|
744
|
+
/**
|
|
745
|
+
* 登录后立刻问一次身份(有超时,失败返回 null)。
|
|
746
|
+
*
|
|
747
|
+
* 这一步是「尽量做对」而不是必须:拿不到身份也要能把账号存下来,只是会退回
|
|
748
|
+
* 「每次登录各自成一条」的旧行为。
|
|
749
|
+
*/
|
|
750
|
+
async function probeIdentity(token) {
|
|
751
|
+
const credential = {
|
|
752
|
+
token: token.securityToken,
|
|
753
|
+
accessKeyId: token.accessKeyId,
|
|
754
|
+
secretAccessKey: token.secretAccessKey,
|
|
755
|
+
securityToken: token.securityToken
|
|
756
|
+
};
|
|
757
|
+
const controller = new AbortController();
|
|
758
|
+
const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
|
|
759
|
+
timer.unref?.();
|
|
760
|
+
try {
|
|
761
|
+
return await fetchCallerIdentity(credential, controller.signal);
|
|
762
|
+
} catch {
|
|
763
|
+
return null;
|
|
764
|
+
} finally {
|
|
765
|
+
clearTimeout(timer);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** 账号还能用吗(没被标 stale、也没过期)。 */
|
|
769
|
+
function accountUsable(summary) {
|
|
770
|
+
if (summary.stale) return false;
|
|
771
|
+
if (typeof summary.expiration !== "string") return true;
|
|
772
|
+
const at = Date.parse(summary.expiration);
|
|
773
|
+
return !Number.isFinite(at) || at > Date.now();
|
|
774
|
+
}
|
|
775
|
+
/** 池状态 -> 卡片能直接渲染的槽位信息。 */
|
|
776
|
+
function slotsOf(deps) {
|
|
777
|
+
const runtime = deps.pool?.() ?? [];
|
|
778
|
+
const now = Date.now();
|
|
779
|
+
return runtime.map((account) => {
|
|
780
|
+
const state = account.disabled ? "disabled" : now < account.coolUntil ? "cooling" : now < account.cappedUntil ? "capped" : "ok";
|
|
781
|
+
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;
|
|
782
|
+
return {
|
|
783
|
+
id: account.id,
|
|
784
|
+
active: account.activeConcurrent,
|
|
785
|
+
max: account.maxConcurrent,
|
|
786
|
+
state,
|
|
787
|
+
...detail === void 0 ? {} : { detail }
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
/** 用量账本 -> 卡片文档;没有账本(未注入)时给一份空壳,卡片就不会瞎显示。 */
|
|
792
|
+
function usageOf(deps) {
|
|
793
|
+
const snapshot = deps.usage?.();
|
|
794
|
+
const noTotals = {
|
|
795
|
+
requests: 0,
|
|
796
|
+
promptTokens: 0,
|
|
797
|
+
completionTokens: 0,
|
|
798
|
+
totalTokens: 0
|
|
799
|
+
};
|
|
800
|
+
return {
|
|
801
|
+
today: snapshot?.today ?? noTotals,
|
|
802
|
+
allTime: snapshot?.allTime ?? noTotals,
|
|
803
|
+
byModelToday: snapshot?.byModelToday ?? [],
|
|
804
|
+
days: snapshot?.days ?? 0,
|
|
805
|
+
...snapshot?.lastAt === void 0 ? {} : { lastAt: snapshot.lastAt },
|
|
806
|
+
slots: slotsOf(deps)
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
async function buildStatus(deps) {
|
|
810
|
+
const models = deps.models().map((m) => ({
|
|
811
|
+
id: m.id,
|
|
812
|
+
name: m.name
|
|
813
|
+
}));
|
|
814
|
+
const accounts = await deps.store.list();
|
|
815
|
+
const cred = await deps.store.current();
|
|
816
|
+
const usage = usageOf(deps);
|
|
817
|
+
if (accounts.length === 0 || cred === void 0) return {
|
|
818
|
+
status: "signed-out",
|
|
819
|
+
models,
|
|
820
|
+
accounts,
|
|
821
|
+
usage
|
|
822
|
+
};
|
|
823
|
+
return {
|
|
824
|
+
status: accounts.some(accountUsable) ? "signed-in" : "expired",
|
|
825
|
+
...cred.label === void 0 ? {} : { label: cred.label },
|
|
826
|
+
...cred.storedAt === void 0 ? {} : { storedAt: cred.storedAt },
|
|
827
|
+
models,
|
|
828
|
+
accounts,
|
|
829
|
+
usage
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
/** GET /api/codearts/status */
|
|
833
|
+
function statusHandler(deps) {
|
|
834
|
+
return async (req, res) => {
|
|
835
|
+
if (req.method !== "GET") {
|
|
836
|
+
json(res, 405, { error: "method not allowed" });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (!loopbackRequest(req)) {
|
|
840
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
json(res, 200, await buildStatus(deps));
|
|
845
|
+
} catch (error) {
|
|
846
|
+
json(res, 500, { error: safeMessage(error) });
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
/** GET /api/codearts/models — currently offered model ids (honours allowlist). */
|
|
851
|
+
function modelsHandler(deps) {
|
|
852
|
+
return async (req, res) => {
|
|
853
|
+
if (req.method !== "GET") {
|
|
854
|
+
json(res, 405, { error: "method not allowed" });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (!loopbackRequest(req)) {
|
|
858
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
json(res, 200, { models: filterEnabledModels(deps.models(), deps.enabledModels?.()).map((m) => ({
|
|
862
|
+
id: m.id,
|
|
863
|
+
name: m.name
|
|
864
|
+
})) });
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* GET/POST /api/codearts/enabled-models — 模型勾选的读写。
|
|
869
|
+
* GET 返回 selection(choices + restricted + writable);POST 接收 {enabledModels}
|
|
870
|
+
* 写入 settings(全选时传空数组表示"全部")。与 codebuddy 的 /plugins/<id>/enabled-models 对齐。
|
|
871
|
+
*/
|
|
872
|
+
function modelsSelectionHandler(deps) {
|
|
873
|
+
return async (req, res) => {
|
|
874
|
+
if (!loopbackRequest(req)) {
|
|
875
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (req.method === "GET") {
|
|
879
|
+
const all = deps.models().map((m) => ({
|
|
880
|
+
id: m.id,
|
|
881
|
+
name: m.name,
|
|
882
|
+
free: isBenefitModel(m.id)
|
|
883
|
+
}));
|
|
884
|
+
const writable = deps.setEnabledModels !== void 0;
|
|
885
|
+
json(res, 200, { selection: selectionOf(all, deps.enabledModels?.(), writable) });
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (req.method === "POST") {
|
|
889
|
+
if (!deps.setEnabledModels) {
|
|
890
|
+
json(res, 403, { error: "selection-not-writable" });
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const type = req.headers["content-type"];
|
|
894
|
+
if (typeof type !== "string" || !type.trim().toLowerCase().startsWith("application/json")) {
|
|
895
|
+
json(res, 415, { error: "content-type must be application/json" });
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
const body = JSON.parse(await readBody(req));
|
|
900
|
+
if (!Array.isArray(body.enabledModels) || !body.enabledModels.every((x) => typeof x === "string")) {
|
|
901
|
+
json(res, 400, { error: "expected {\"enabledModels\": string[]}" });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (!await deps.setEnabledModels(body.enabledModels)) {
|
|
905
|
+
json(res, 403, { error: "selection-not-writable" });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
json(res, 200, { selection: selectionOf(deps.models().map((m) => ({
|
|
909
|
+
id: m.id,
|
|
910
|
+
name: m.name,
|
|
911
|
+
free: isBenefitModel(m.id)
|
|
912
|
+
})), deps.enabledModels?.(), true) });
|
|
913
|
+
} catch (error) {
|
|
914
|
+
json(res, 500, { error: safeMessage(error) });
|
|
915
|
+
}
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
json(res, 405, { error: "method not allowed" });
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/** POST /api/codearts/token { token, label? } — store a pasted token. */
|
|
922
|
+
function tokenPostHandler(deps) {
|
|
923
|
+
return async (req, res) => {
|
|
924
|
+
if (req.method !== "POST") {
|
|
925
|
+
json(res, 405, { error: "method not allowed" });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (!loopbackRequest(req)) {
|
|
929
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const type = req.headers["content-type"];
|
|
933
|
+
if (typeof type !== "string" || !type.trim().toLowerCase().startsWith("application/json")) {
|
|
934
|
+
json(res, 415, { error: "content-type must be application/json" });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
const body = JSON.parse(await readBody(req));
|
|
939
|
+
const token = typeof body?.token === "string" ? body.token.trim() : "";
|
|
940
|
+
if (token === "") {
|
|
941
|
+
json(res, 400, { error: "expected {\"token\": string}" });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const label = typeof body?.label === "string" && body.label.trim() !== "" ? body.label.trim() : void 0;
|
|
945
|
+
await deps.store.set(token, label);
|
|
946
|
+
json(res, 200, await buildStatus(deps));
|
|
947
|
+
} catch (error) {
|
|
948
|
+
json(res, 500, { error: safeMessage(error) });
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
/** DELETE /api/codearts/token — forget the stored token. */
|
|
953
|
+
function tokenDeleteHandler(deps) {
|
|
954
|
+
return async (req, res) => {
|
|
955
|
+
if (req.method !== "DELETE") {
|
|
956
|
+
json(res, 405, { error: "method not allowed" });
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
|
|
960
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
await deps.store.clear();
|
|
965
|
+
json(res, 200, await buildStatus(deps));
|
|
966
|
+
} catch (error) {
|
|
967
|
+
json(res, 500, { error: safeMessage(error) });
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* DELETE /api/codearts/accounts?id=<accountId> — 删掉一个账号。
|
|
973
|
+
*
|
|
974
|
+
* id 必须显式给:多账号下「删掉当前那个」是歧义的。改状态的路由,所以和
|
|
975
|
+
* /token 一样走 Host + Origin 双校验。
|
|
976
|
+
*/
|
|
977
|
+
function accountsHandler(deps) {
|
|
978
|
+
return async (req, res) => {
|
|
979
|
+
if (req.method !== "DELETE") {
|
|
980
|
+
json(res, 405, { error: "method not allowed" });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (!loopbackRequest(req)) {
|
|
984
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const id = (new URL(req.url ?? "", "http://localhost").searchParams.get("id") ?? "").trim();
|
|
988
|
+
if (id === "") {
|
|
989
|
+
json(res, 400, { error: "expected ?id=<accountId>" });
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
await deps.store.remove(id);
|
|
994
|
+
json(res, 200, await buildStatus(deps));
|
|
995
|
+
} catch (error) {
|
|
996
|
+
json(res, 500, { error: safeMessage(error) });
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/** Mount the status / models / token routes. */
|
|
1001
|
+
function registerCodeArtsStatusRoute(ctx, deps) {
|
|
1002
|
+
ctx.effect(() => {
|
|
1003
|
+
const activeLogins = /* @__PURE__ */ new Map();
|
|
1004
|
+
/** 已结束登录的终态,供迟到的轮询读取。被读一次即删。 */
|
|
1005
|
+
const recentLogins = /* @__PURE__ */ new Map();
|
|
1006
|
+
const disposeStatus = ctx.webServer.register({
|
|
1007
|
+
kind: "exact",
|
|
1008
|
+
path: CODEARTS_STATUS_PATH,
|
|
1009
|
+
handler: statusHandler(deps)
|
|
1010
|
+
});
|
|
1011
|
+
const disposeModels = ctx.webServer.register({
|
|
1012
|
+
kind: "exact",
|
|
1013
|
+
path: CODEARTS_MODELS_PATH,
|
|
1014
|
+
handler: modelsHandler(deps)
|
|
1015
|
+
});
|
|
1016
|
+
const disposeModelsSelection = ctx.webServer.register({
|
|
1017
|
+
kind: "exact",
|
|
1018
|
+
path: CODEARTS_MODELS_SELECTION_PATH,
|
|
1019
|
+
handler: modelsSelectionHandler(deps)
|
|
1020
|
+
});
|
|
1021
|
+
const disposeToken = ctx.webServer.register({
|
|
1022
|
+
kind: "exact",
|
|
1023
|
+
path: CODEARTS_TOKEN_PATH,
|
|
1024
|
+
handler: tokenHandler(deps)
|
|
1025
|
+
});
|
|
1026
|
+
const disposeLoginStart = ctx.webServer.register({
|
|
1027
|
+
kind: "exact",
|
|
1028
|
+
path: CODEARTS_LOGIN_PATH,
|
|
1029
|
+
handler: loginStartHandler(deps, activeLogins, recentLogins)
|
|
1030
|
+
});
|
|
1031
|
+
const disposeLoginStatus = ctx.webServer.register({
|
|
1032
|
+
kind: "exact",
|
|
1033
|
+
path: CODEARTS_LOGIN_STATUS_PATH,
|
|
1034
|
+
handler: loginPollHandler(deps, activeLogins, recentLogins)
|
|
1035
|
+
});
|
|
1036
|
+
const disposeAccounts = ctx.webServer.register({
|
|
1037
|
+
kind: "exact",
|
|
1038
|
+
path: CODEARTS_ACCOUNTS_PATH,
|
|
1039
|
+
handler: accountsHandler(deps)
|
|
1040
|
+
});
|
|
1041
|
+
return () => {
|
|
1042
|
+
disposeStatus();
|
|
1043
|
+
disposeModels();
|
|
1044
|
+
disposeModelsSelection();
|
|
1045
|
+
disposeToken();
|
|
1046
|
+
disposeLoginStart();
|
|
1047
|
+
disposeLoginStatus();
|
|
1048
|
+
disposeAccounts();
|
|
1049
|
+
for (const s of activeLogins.values()) s.close();
|
|
1050
|
+
activeLogins.clear();
|
|
1051
|
+
};
|
|
1052
|
+
}, "dsh-codearts: Web status route");
|
|
1053
|
+
}
|
|
1054
|
+
/** /api/codearts/token — 一个席位按方法分派(POST 写入,DELETE 清除)。 */
|
|
1055
|
+
function tokenHandler(deps) {
|
|
1056
|
+
const post = tokenPostHandler(deps);
|
|
1057
|
+
const remove = tokenDeleteHandler(deps);
|
|
1058
|
+
return (req, res) => req.method === "DELETE" ? remove(req, res) : post(req, res);
|
|
1059
|
+
}
|
|
1060
|
+
/** 记一次登录终态;超出容量就丢最旧的,别让这个表无限长。 */
|
|
1061
|
+
function recordLoginOutcome(recent, url, outcome) {
|
|
1062
|
+
if (recent.size >= 20 && !recent.has(url)) {
|
|
1063
|
+
const oldest = recent.keys().next().value;
|
|
1064
|
+
if (oldest !== void 0) recent.delete(oldest);
|
|
1065
|
+
}
|
|
1066
|
+
recent.set(url, outcome);
|
|
1067
|
+
}
|
|
1068
|
+
/** POST /api/codearts/login — 启动本地 OAuth 回调,返回授权 URL。 */
|
|
1069
|
+
function loginStartHandler(deps, active, recent) {
|
|
1070
|
+
return async (req, res) => {
|
|
1071
|
+
if (req.method !== "POST") {
|
|
1072
|
+
json(res, 405, { error: "method not allowed" });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
|
|
1076
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
const session = await startLogin();
|
|
1081
|
+
active.set(session.url, session);
|
|
1082
|
+
session.promise.then(async (token) => {
|
|
1083
|
+
active.delete(session.url);
|
|
1084
|
+
try {
|
|
1085
|
+
const identity = await probeIdentity(token);
|
|
1086
|
+
const id = await deps.store.upsertFromOAuth(token, void 0, identity ?? void 0);
|
|
1087
|
+
if (identity !== null && id !== identity.userId) await deps.store.adoptIdentity(id, identity);
|
|
1088
|
+
recordLoginOutcome(recent, session.url, "done");
|
|
1089
|
+
} catch {
|
|
1090
|
+
recordLoginOutcome(recent, session.url, "error");
|
|
1091
|
+
}
|
|
1092
|
+
}, () => {
|
|
1093
|
+
active.delete(session.url);
|
|
1094
|
+
recordLoginOutcome(recent, session.url, "error");
|
|
1095
|
+
});
|
|
1096
|
+
const timer = setTimeout(() => {
|
|
1097
|
+
session.close();
|
|
1098
|
+
if (active.delete(session.url)) recordLoginOutcome(recent, session.url, "error");
|
|
1099
|
+
}, 3e5);
|
|
1100
|
+
session.promise.finally(() => clearTimeout(timer)).catch(() => {});
|
|
1101
|
+
json(res, 200, {
|
|
1102
|
+
url: session.url,
|
|
1103
|
+
port: session.port
|
|
1104
|
+
});
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
json(res, 500, { error: safeMessage(error) });
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
/** GET /api/codearts/login/status — 轮询登录结果。 */
|
|
1111
|
+
function loginPollHandler(_deps, active, recent) {
|
|
1112
|
+
return async (req, res) => {
|
|
1113
|
+
if (req.method !== "GET") {
|
|
1114
|
+
json(res, 405, { error: "method not allowed" });
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
|
|
1118
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const key = new URL(req.url ?? "", "http://localhost").searchParams.get("url") ?? "";
|
|
1122
|
+
if (active.has(key)) {
|
|
1123
|
+
json(res, 200, { status: "pending" });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const outcome = recent.get(key);
|
|
1127
|
+
if (outcome !== void 0) {
|
|
1128
|
+
recent.delete(key);
|
|
1129
|
+
json(res, 200, { status: outcome });
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
json(res, 200, { status: "idle" });
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
//#endregion
|
|
1136
|
+
//#region src/settings-compat.ts
|
|
1137
|
+
const compatModule = settingsModule;
|
|
1138
|
+
function plainSettingsSchema(schema) {
|
|
1139
|
+
const clone = structuredClone(schema.toJSON());
|
|
1140
|
+
for (const node of Object.values(clone.refs ?? {})) if (node !== null && typeof node === "object" && node.meta !== void 0) delete node.meta.volatile;
|
|
1141
|
+
return z(clone);
|
|
1142
|
+
}
|
|
1143
|
+
function entryNamespaceOf(ctx) {
|
|
1144
|
+
const compat = ctx;
|
|
1145
|
+
const entry = compat.fiber?.entry;
|
|
1146
|
+
const candidates = [
|
|
1147
|
+
entry?.options?.id,
|
|
1148
|
+
entry?.id,
|
|
1149
|
+
compat.loader?.locate?.(compat.fiber)
|
|
1150
|
+
];
|
|
1151
|
+
for (const candidate of candidates) if (typeof candidate === "string" && candidate !== "") return candidate;
|
|
1152
|
+
}
|
|
1153
|
+
function resolveSettingsNamespaceCompat(ctx, provider, fallback) {
|
|
1154
|
+
if (compatModule.installSettingsSection !== void 0) return fallback;
|
|
1155
|
+
const candidate = provider;
|
|
1156
|
+
if (candidate.installSection !== void 0) return fallback;
|
|
1157
|
+
if (candidate.configure !== void 0) return entryNamespaceOf(ctx) ?? fallback;
|
|
1158
|
+
return fallback;
|
|
1159
|
+
}
|
|
1160
|
+
function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
|
|
1161
|
+
const legacyInstaller = compatModule.installSettingsSection;
|
|
1162
|
+
if (legacyInstaller !== void 0) {
|
|
1163
|
+
legacyInstaller(ctx, ns, plainSettingsSchema(schema), entry, hooks);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
ctx.inject(["settings"], (sctx) => {
|
|
1167
|
+
const provider = sctx.get("settings");
|
|
1168
|
+
if (provider === void 0) return;
|
|
1169
|
+
if (provider.installSection !== void 0) {
|
|
1170
|
+
provider.installSection(ctx, ns, plainSettingsSchema(schema), entry, hooks);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (provider.configure === void 0) throw new TypeError("dsh-settings exposes neither installSection nor configure");
|
|
1174
|
+
sctx.effect(() => {
|
|
1175
|
+
const dispose = provider.configure?.({ auto: false }, ctx.fiber);
|
|
1176
|
+
return typeof dispose === "function" ? dispose : () => {};
|
|
1177
|
+
});
|
|
1178
|
+
const namespace = entryNamespaceOf(ctx);
|
|
1179
|
+
if (namespace !== void 0) {
|
|
1180
|
+
const refresh = () => {
|
|
1181
|
+
const descriptor = provider.describe?.().find((entry) => String(entry.ns) === namespace);
|
|
1182
|
+
if (descriptor?.value !== void 0) hooks.setSource?.(() => descriptor.value);
|
|
1183
|
+
hooks.onChange();
|
|
1184
|
+
};
|
|
1185
|
+
const settingsEvents = sctx;
|
|
1186
|
+
sctx.effect(() => settingsEvents.on("settings/document-updated", (updated) => {
|
|
1187
|
+
if (String(updated) === namespace) refresh();
|
|
1188
|
+
}));
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/index.ts
|
|
1194
|
+
/** Stable Cordis plugin name. */
|
|
1195
|
+
const name = "llm-codearts";
|
|
1196
|
+
/** The llm service is required before the provider can register. */
|
|
1197
|
+
const inject = ["llm"];
|
|
1198
|
+
/** Settings namespace owning the configuration card. */
|
|
1199
|
+
const CODEARTS_SETTINGS_NS = "codearts";
|
|
1200
|
+
function unwrapVolatileValue(value) {
|
|
1201
|
+
if (typeof value === "object" && value !== null && typeof value.get === "function") return value.get();
|
|
1202
|
+
return value;
|
|
1203
|
+
}
|
|
1204
|
+
function unwrapConfig(value) {
|
|
1205
|
+
if (typeof value !== "object" || value === null) return {};
|
|
1206
|
+
const out = {};
|
|
1207
|
+
for (const [key, field] of Object.entries(value)) out[key] = unwrapVolatileValue(field);
|
|
1208
|
+
return out;
|
|
1209
|
+
}
|
|
1210
|
+
const Config = z.object({ enabledModels: z.array(z.string()).description("Model ids offered in the model pickers (empty means every model)").volatile() });
|
|
1211
|
+
function settingsCompatNamespace(ctx) {
|
|
1212
|
+
return resolveSettingsNamespaceCompat(ctx, ctx.get("settings"), CODEARTS_SETTINGS_NS);
|
|
1213
|
+
}
|
|
1214
|
+
function apply(ctx, rawConfig) {
|
|
1215
|
+
const config = unwrapConfig(rawConfig);
|
|
1216
|
+
const accountsPath = codeartsAccountsPath();
|
|
1217
|
+
const store = new CodeArtsAccountStore(accountsPath);
|
|
1218
|
+
const pool = new CodeArtsAccountPool([]);
|
|
1219
|
+
const catalog = new CodeArtsCatalog();
|
|
1220
|
+
const usage = new CodeArtsUsageTracker({ path: codeartsUsagePath() });
|
|
1221
|
+
let current = () => config;
|
|
1222
|
+
const enabledModels = () => current().enabledModels;
|
|
1223
|
+
const setEnabledModels = async (ids) => {
|
|
1224
|
+
const settings = ctx.get("settings");
|
|
1225
|
+
if (settings === void 0) return false;
|
|
1226
|
+
await settings.update(settingsCompatNamespace(ctx), { enabledModels: [...ids] });
|
|
1227
|
+
return true;
|
|
1228
|
+
};
|
|
1229
|
+
ctx.inject(["webServer"], (webCtx) => registerCodeArtsStatusRoute(webCtx, {
|
|
1230
|
+
store,
|
|
1231
|
+
models: () => catalog.current(),
|
|
1232
|
+
enabledModels,
|
|
1233
|
+
setEnabledModels,
|
|
1234
|
+
pool: () => pool.list(),
|
|
1235
|
+
usage: () => usage.snapshot()
|
|
1236
|
+
}));
|
|
1237
|
+
installSettingsSectionCompat(ctx, CODEARTS_SETTINGS_NS, Config, config, {
|
|
1238
|
+
setSource(source) {
|
|
1239
|
+
current = () => unwrapConfig(source());
|
|
1240
|
+
},
|
|
1241
|
+
onChange() {}
|
|
1242
|
+
});
|
|
1243
|
+
let stopped = false;
|
|
1244
|
+
ctx.effect(() => () => {
|
|
1245
|
+
stopped = true;
|
|
1246
|
+
usage.flush().catch(() => {});
|
|
1247
|
+
shim.close().catch(() => {});
|
|
1248
|
+
});
|
|
1249
|
+
const CATALOG_REFRESH_MS = 36e5;
|
|
1250
|
+
const abort = new AbortController();
|
|
1251
|
+
const discover = async () => {
|
|
1252
|
+
if (stopped) return;
|
|
1253
|
+
try {
|
|
1254
|
+
const outcome = await refreshCatalog(store, catalog, abort.signal);
|
|
1255
|
+
if (outcome === void 0 || stopped) return;
|
|
1256
|
+
if (outcome.anySucceeded) {
|
|
1257
|
+
const free = outcome.models.filter((model) => model.benefit === true).length;
|
|
1258
|
+
ctx.logger.debug(`dsh-codearts: model discovery refreshed the catalog (${outcome.models.length} models, ${free} benefit)`);
|
|
1259
|
+
} else ctx.logger.warn("dsh-codearts: model discovery found nothing usable; keeping the current catalog", outcome.accounts.flatMap((account) => account.errors));
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
ctx.logger.debug("dsh-codearts: model discovery failed; keeping the current catalog", error);
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
ctx.effect(() => {
|
|
1265
|
+
const timer = setInterval(() => void discover(), CATALOG_REFRESH_MS);
|
|
1266
|
+
timer.unref?.();
|
|
1267
|
+
return () => {
|
|
1268
|
+
abort.abort();
|
|
1269
|
+
clearInterval(timer);
|
|
1270
|
+
};
|
|
1271
|
+
});
|
|
1272
|
+
const shim = createCodeArtsShim({
|
|
1273
|
+
store,
|
|
1274
|
+
catalog,
|
|
1275
|
+
pool,
|
|
1276
|
+
usage,
|
|
1277
|
+
logger: ctx.logger
|
|
1278
|
+
});
|
|
1279
|
+
usage.load().catch(() => {});
|
|
1280
|
+
shim.ready.then(() => {
|
|
1281
|
+
if (stopped) return;
|
|
1282
|
+
discover();
|
|
1283
|
+
const codearts = createCodeArtsAdapter({
|
|
1284
|
+
shim,
|
|
1285
|
+
catalog,
|
|
1286
|
+
enabledModels,
|
|
1287
|
+
resolveAttachments: () => ctx.get("attachments")
|
|
1288
|
+
});
|
|
1289
|
+
let releaseAdapter;
|
|
1290
|
+
let releaseDirectory;
|
|
1291
|
+
try {
|
|
1292
|
+
releaseAdapter = ctx.llm.registerAdapter([CODEARTS_PROVIDER], codearts.adapter);
|
|
1293
|
+
releaseDirectory = ctx.llm.registerConfigurableProviders([{
|
|
1294
|
+
provider: CODEARTS_PROVIDER,
|
|
1295
|
+
displayName: "CodeArts",
|
|
1296
|
+
settingsNs: settingsCompatNamespace(ctx),
|
|
1297
|
+
settingsPath: [],
|
|
1298
|
+
declared: false
|
|
1299
|
+
}]);
|
|
1300
|
+
} finally {
|
|
1301
|
+
if (releaseAdapter === void 0 || releaseDirectory === void 0) {
|
|
1302
|
+
releaseAdapter?.();
|
|
1303
|
+
releaseDirectory?.();
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
ctx.effect(() => () => {
|
|
1308
|
+
releaseAdapter?.();
|
|
1309
|
+
releaseDirectory?.();
|
|
1310
|
+
});
|
|
1311
|
+
} catch {
|
|
1312
|
+
releaseAdapter?.();
|
|
1313
|
+
releaseDirectory?.();
|
|
1314
|
+
}
|
|
1315
|
+
}).catch((error) => {
|
|
1316
|
+
ctx.logger.error("dsh-codearts: loopback shim failed to start; provider not registered", error);
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
//#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 };
|