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