dsh-xdpool 2.6.1 → 2.6.2

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.js CHANGED
@@ -3,7 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import crypto, { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, truncateSync, writeFileSync } from "node:fs";
6
- import { chmod, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
+ import { chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { execFile, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -3589,12 +3589,46 @@ async function cheapIdentityIdFromFile(path) {
3589
3589
  }
3590
3590
  }
3591
3591
  /**
3592
+ * The `uin` of a credential file, read WITHOUT the expiry gate.
3593
+ *
3594
+ * `parseWorkBuddyAuth` deliberately refuses an expired document, which is exactly the
3595
+ * state this is asked about — so the identity has to come from the raw JSON. `uin` and not
3596
+ * `uid`: `uin` is the billing identity, so two sign-ins of one person match even when the
3597
+ * desktop app wrote them from different installs.
3598
+ */
3599
+ async function cheapUinFromFile(path) {
3600
+ let parsed;
3601
+ try {
3602
+ parsed = JSON.parse(await readFile(path, "utf8"));
3603
+ } catch {
3604
+ return;
3605
+ }
3606
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
3607
+ const document = parsed;
3608
+ const uin = (typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document)["uin"];
3609
+ return typeof uin === "string" && uin !== "" ? uin : void 0;
3610
+ }
3611
+ /**
3592
3612
  * Why a file could not become an account.
3593
3613
  *
3594
- * Distinguishing "encrypted" matters most: it is actionable (start the desktop
3595
- * app, or set the executable override), whereas "malformed" means the file is
3596
- * simply not a credential. Reporting them alike would send the user looking for
3597
- * a fix that cannot work.
3614
+ * The distinctions matter because the REMEDIES differ, and a wrong label sends the reader
3615
+ * after a fix that cannot work:
3616
+ *
3617
+ * - `encrypted` — the desktop app holds the key; start it, or set the executable
3618
+ * override. Actionable.
3619
+ * - `expired` — the file IS a credential, correctly formed, whose refresh window has
3620
+ * closed. Nothing to fix: sign in again, or delete the snapshot. This
3621
+ * used to be reported as `malformed`, which reads as "this file is not
3622
+ * a credential" and sends the user looking for a format problem that
3623
+ * does not exist — the reported confusion over three 2026-10-08
3624
+ * snapshots whose tokens had simply aged out.
3625
+ * - `unreadable` — the bytes could not be read at all.
3626
+ * - `malformed` — genuine: unparseable, or parseable but not a credential shape.
3627
+ *
3628
+ * `expired` is decided by re-parsing the document the same way `parseWorkBuddyAuth` does,
3629
+ * which is the only way to tell "a credential that aged out" from "not a credential". A
3630
+ * document that parses as JSON but carries no token stays `malformed`: those are three
3631
+ * different facts and collapsing any two of them costs the reader the diagnosis.
3598
3632
  */
3599
3633
  async function skipReasonFor(path) {
3600
3634
  let text;
@@ -3604,12 +3638,28 @@ async function skipReasonFor(path) {
3604
3638
  return "unreadable";
3605
3639
  }
3606
3640
  if (text.includes("\"$wbEncrypted\"")) return "encrypted";
3641
+ let parsed;
3607
3642
  try {
3608
- JSON.parse(text);
3609
- return "malformed";
3643
+ parsed = JSON.parse(text);
3610
3644
  } catch {
3611
3645
  return "malformed";
3612
3646
  }
3647
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "malformed";
3648
+ const document = parsed;
3649
+ const auth = typeof document["auth"] === "object" && document["auth"] !== null ? document["auth"] : document;
3650
+ const token = auth["accessToken"];
3651
+ if (typeof token !== "string" || token === "") return "malformed";
3652
+ /**
3653
+ * It HAS a token, so the only remaining reason to skip it is a closed refresh window.
3654
+ * Mirrors the guard in `parseWorkBuddyAuth`, and falls back to `malformed` when the
3655
+ * window cannot be read — an unknown expiry is not evidence of expiry.
3656
+ */
3657
+ const refreshRaw = auth["refreshExpiresAt"];
3658
+ if (typeof refreshRaw === "number") {
3659
+ const refreshAt = expiryToMs$1(refreshRaw);
3660
+ if (refreshAt > 0 && refreshAt < Date.now()) return "expired";
3661
+ }
3662
+ return "malformed";
3613
3663
  }
3614
3664
  /**
3615
3665
  * Build the field opener, or undefined when the app cannot supply its key.
@@ -3911,6 +3961,46 @@ var WorkBuddyAccountPool = class {
3911
3961
  skippedFilesInOrder() {
3912
3962
  return this.skippedFiles;
3913
3963
  }
3964
+ /**
3965
+ * Delete expired snapshots that a newer credential for the same account supersedes.
3966
+ *
3967
+ * Returns the paths actually removed. See the call site for the full safety rule; the
3968
+ * short version is that it touches ONLY files classified `expired`, ONLY when the same
3969
+ * `uin` is already served by a different (loaded) file, and never a live file.
3970
+ *
3971
+ * `uin` rather than `uid`: it is the billing identity, so two sign-ins of one person
3972
+ * match even when the desktop app wrote them from different installs. A file whose
3973
+ * identity cannot be read is left alone — deleting a credential on a guess is worse than
3974
+ * keeping a dead one on the card.
3975
+ */
3976
+ async pruneExpiredSnapshots(skipped, loaded) {
3977
+ const expired = skipped.filter((entry) => entry.reason === "expired");
3978
+ if (expired.length === 0) return [];
3979
+ const liveNames = /* @__PURE__ */ new Set([WORKBUDDY_LIVE_FILENAME, "workbuddy-desktop-ai.info"]);
3980
+ /**
3981
+ * The identities already served by a file that loaded in this same scan.
3982
+ *
3983
+ * Taken from the parsed credentials rather than by re-reading each file: `found`
3984
+ * already carries `uin`, and a second read of a file that just parsed would only add a
3985
+ * chance to fail.
3986
+ */
3987
+ const servedUins = /* @__PURE__ */ new Set();
3988
+ for (const credential of loaded) if (credential.uin !== void 0 && credential.uin !== "") servedUins.add(credential.uin);
3989
+ if (servedUins.size === 0) return [];
3990
+ const removed = [];
3991
+ for (const entry of expired) {
3992
+ if (liveNames.has(basename(entry.path))) continue;
3993
+ const uin = await cheapUinFromFile(entry.path);
3994
+ if (uin === void 0 || !servedUins.has(uin)) continue;
3995
+ try {
3996
+ await unlink(entry.path);
3997
+ removed.push(entry.path);
3998
+ } catch (error) {
3999
+ this.logger?.warn?.(`dsh-xdpool: could not remove expired snapshot ${entry.path}: ${String(error)}`);
4000
+ }
4001
+ }
4002
+ return removed;
4003
+ }
3914
4004
  /** Rescan the auth directories and merge newly discovered accounts. */
3915
4005
  async scan() {
3916
4006
  const found = [];
@@ -3937,6 +4027,33 @@ var WorkBuddyAccountPool = class {
3937
4027
  }
3938
4028
  this.skippedFiles = skipped;
3939
4029
  if (skipped.length > 0) this.logger?.warn?.(`dsh-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
4030
+ /**
4031
+ * Remove expired snapshots whose account is served by a newer credential.
4032
+ *
4033
+ * A rotated sign-in leaves the old snapshot on disk forever: the desktop app writes a
4034
+ * new `workbuddy-desktop.<ts>.<pid>.<uuid>.info` each time and never deletes the
4035
+ * previous one. Within a month every one of them passes its `refreshExpiresAt` and the
4036
+ * card starts listing a growing pile of "expired" files the user cannot act on —
4037
+ * they are dead weight from a sign-in that already succeeded.
4038
+ *
4039
+ * The safety rule is narrow on purpose, because deleting a credential file is
4040
+ * destructive and the auth directory belongs to another application:
4041
+ *
4042
+ * - ONLY files this scan classified as `expired` — never `encrypted`, `unreadable`
4043
+ * or `malformed`, which may be the very file holding a working key;
4044
+ * - ONLY when the SAME account (by `uin`, the billing identity) was loaded from a
4045
+ * DIFFERENT file, i.e. a newer credential exists to serve it;
4046
+ * - never the live `workbuddy-desktop.info` / `workbuddy-desktop-ai.info`, which the
4047
+ * running app owns and rewrites.
4048
+ *
4049
+ * Anything skipped for not meeting all three is left alone and keeps its entry on the
4050
+ * card, so the user still learns it is there.
4051
+ */
4052
+ const cleaned = await this.pruneExpiredSnapshots(skipped, found);
4053
+ if (cleaned.length > 0) {
4054
+ this.skippedFiles = skipped.filter((entry) => !cleaned.includes(entry.path));
4055
+ this.logger?.info?.(`dsh-xdpool: removed ${cleaned.length} expired credential snapshot(s) superseded by a newer sign-in`);
4056
+ }
3940
4057
  if (this.externalSource !== void 0) try {
3941
4058
  const external = await this.externalSource();
3942
4059
  for (const credential of external) {
@@ -4040,6 +4157,86 @@ var WorkBuddyAccountPool = class {
4040
4157
  });
4041
4158
  }
4042
4159
  /**
4160
+ * The pool ordered into three bands, so a resting account is deferred instead of
4161
+ * discarded.
4162
+ *
4163
+ * `available()` answers a binary question, and a binary answer loses the
4164
+ * distinction that matters most when a pool is under pressure:
4165
+ *
4166
+ * - an account that still HAS credits but was rate-limited a moment ago is
4167
+ * healthy — it should be tried AFTER the others, not treated as gone;
4168
+ * - an account resting after a failure is the last resort, but it is still a
4169
+ * resort: when it is the only one, trying it beats failing the request.
4170
+ *
4171
+ * A pool that drops both from the list turns "every account is cooling for
4172
+ * twenty more seconds" into an error the user reads as "no account available",
4173
+ * which is a different and much more alarming statement.
4174
+ *
4175
+ * The bands:
4176
+ *
4177
+ * 1. `ready` — passes every gate `available()` checks.
4178
+ * 2. `limited` — region-eligible and not disabled/reserved, but cooling for
4179
+ * this model or account-wide. Ordered by when it comes back,
4180
+ * so the account that recovers first is spent first.
4181
+ * 3. `resting` — sign-in was rejected by the upstream. It never heals on a
4182
+ * timer, so it is only tried when 1 and 2 are both empty.
4183
+ *
4184
+ * `disabled` and `reserve` accounts appear in NO band: those are user choices,
4185
+ * and overriding them is not resilience, it is ignoring an instruction.
4186
+ *
4187
+ * Kept separate from `available()` rather than replacing it, so the binary
4188
+ * question still has a binary answer for callers that want one (diagnostics,
4189
+ * the status document) without either copy drifting from the other.
4190
+ */
4191
+ rankedPool(now, modelId, region, reserveExempt) {
4192
+ const ready = [];
4193
+ const limited = [];
4194
+ const resting = [];
4195
+ for (const account of this.accounts) {
4196
+ if (this.disabledIds.has(account.id)) continue;
4197
+ const reserve = this.creditReserves.get(account.id);
4198
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
4199
+ const balance = this.creditBalances.get(account.id);
4200
+ if (balance !== void 0 && balance <= reserve) continue;
4201
+ }
4202
+ if (region !== void 0 && regionOf(account.credential.domain) !== region) continue;
4203
+ const deadUntil = account.credentialDeadUntilMs ?? 0;
4204
+ const coolUntil = account.cooldownUntilMs;
4205
+ const modelUntil = modelId === void 0 ? 0 : account.modelCooldowns[modelId] ?? 0;
4206
+ if (deadUntil > now) {
4207
+ resting.push(account);
4208
+ continue;
4209
+ }
4210
+ /**
4211
+ * Only a MODEL-level cooldown earns a retry; an account-wide one does not.
4212
+ *
4213
+ * The distinction is what keeps the tier honest. A 429 on one model leaves
4214
+ * the account working for every other model, so trying it again for a
4215
+ * different model is reasonable and often succeeds. An account-wide cooldown
4216
+ * (credits exhausted, quota wall) means the gateway has already said no to
4217
+ * this account as a whole — retrying it spends a round trip to be told the
4218
+ * same thing, and the caller then reports 502 where the truthful answer was
4219
+ * 429. That degradation is worse than the resilience is worth, so the
4220
+ * account-wide case stays out of play entirely.
4221
+ */
4222
+ if (coolUntil > now) continue;
4223
+ if (modelUntil > now) {
4224
+ limited.push({
4225
+ account,
4226
+ backAt: modelUntil
4227
+ });
4228
+ continue;
4229
+ }
4230
+ ready.push(account);
4231
+ }
4232
+ limited.sort((a, b) => a.backAt - b.backAt);
4233
+ return {
4234
+ ready,
4235
+ limited: limited.map((entry) => entry.account),
4236
+ resting
4237
+ };
4238
+ }
4239
+ /**
4043
4240
  * Why no account is available right now, for an accurate error.
4044
4241
  *
4045
4242
  * The pool can be empty for reasons that need OPPOSITE remedies: nobody is
@@ -4324,27 +4521,82 @@ var WorkBuddyAccountPool = class {
4324
4521
  if (this.accounts.length === 0) await this.scan();
4325
4522
  const maxTries = this.accounts.length + 1;
4326
4523
  for (let attempt = 0; attempt < maxTries; attempt += 1) {
4327
- let pool = this.available(Date.now(), modelId, region, reserveExempt);
4328
- if (pool.length === 0 && attempt === 0) {
4524
+ let bands = this.rankedPool(Date.now(), modelId, region, reserveExempt);
4525
+ if (bands.ready.length === 0 && bands.limited.length === 0 && bands.resting.length === 0 && attempt === 0) {
4329
4526
  await this.scan();
4330
- pool = this.available(Date.now(), modelId, region, reserveExempt);
4527
+ bands = this.rankedPool(Date.now(), modelId, region, reserveExempt);
4331
4528
  }
4332
- if (pool.length === 0) return void 0;
4529
+ /**
4530
+ * Fall through the bands, in order — but the fall-through is a STAGE, not a
4531
+ * concatenation, and only `ready` is offered to the strategy when it is
4532
+ * non-empty.
4533
+ *
4534
+ * This distinction is the whole correctness of the change. A strategy like
4535
+ * `balanced` draws from the pool by weight and does not read the order at
4536
+ * all, so handing it `[...ready, ...limited]` would let it pick a cooling
4537
+ * account while a healthy one was sitting right there — silently breaking
4538
+ * the promise "a cooling account is never chosen while a healthy one
4539
+ * exists".
4540
+ *
4541
+ * The wider rule: a band is only tried when every earlier band is EMPTY.
4542
+ * That keeps the truthful error intact — "every account is cooling for this
4543
+ * model" must stay a 429 answered without touching the transport, not a
4544
+ * round trip that comes back 502. Resilience is a last resort, not a
4545
+ * preference, and a pool where nothing is ready has already told the caller
4546
+ * exactly what is wrong.
4547
+ */
4548
+ const stages = bands.ready.length > 0 ? [bands.ready] : [];
4549
+ if (stages.length === 0) return;
4333
4550
  if (this.preferredAccountId !== void 0) {
4334
- const preferred = pool.find((account) => account.id === this.preferredAccountId);
4551
+ const preferred = bands.ready.find((account) => account.id === this.preferredAccountId);
4335
4552
  if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
4336
4553
  }
4337
4554
  if (this.distribution === "sticky") {
4338
- const bound = this.affinityAccount(pool, conversationKey);
4555
+ const bound = this.affinityAccount(bands.ready, conversationKey);
4339
4556
  if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
4340
4557
  }
4341
- const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : this.distribution === "expiry" ? this.pickByExpiry(pool) : pool[0];
4342
- if (account === void 0) return void 0;
4343
- if (!await this.ensureFresh(account)) continue;
4344
- if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
4345
- return account;
4558
+ /**
4559
+ * An account whose balance has never been read goes first, ONCE — but only
4560
+ * under `priority`.
4561
+ *
4562
+ * The reasoning is `priority`-specific: that mode drains one account at a
4563
+ * time, so a fresh account appended to the pool would sit untouched behind an
4564
+ * account that is nearly spent, and the pool would never learn what the new
4565
+ * one has. Trying it once makes its own answer fill in the blank.
4566
+ *
4567
+ * `round-robin`, `balanced`, `sticky` and `expiry` already reach a new
4568
+ * account by their own rule — the cursor walks to it, the weight draw will
4569
+ * eventually pick it, a new conversation moves on to it — so jumping the
4570
+ * queue here would not add information, it would only override the
4571
+ * distribution the user chose. That was a real regression: it pinned every
4572
+ * rotation to the first unprobed account.
4573
+ *
4574
+ * Restricted to `ready` as well: an account that is cooling is not made more
4575
+ * attractive by being unknown, and probing it would spend a request to learn
4576
+ * a number that does not change whether it can answer.
4577
+ */
4578
+ const probe = this.distribution === "priority" && bands.ready.length > 1 ? bands.ready.find((account) => !this.creditBalances.has(account.id)) : void 0;
4579
+ if (probe !== void 0 && this.isStageEligible(probe, bands)) {
4580
+ if (await this.ensureFresh(probe)) return probe;
4581
+ }
4582
+ for (const pool of stages) {
4583
+ const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : this.distribution === "expiry" ? this.pickByExpiry(pool) : pool[0];
4584
+ if (account === void 0) continue;
4585
+ if (!await this.ensureFresh(account)) continue;
4586
+ if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
4587
+ return account;
4588
+ }
4346
4589
  }
4347
4590
  }
4591
+ /**
4592
+ * Whether an account belongs to a band that is currently in play.
4593
+ *
4594
+ * Only `ready` accounts are eligible for the probe shortcut: picking a resting
4595
+ * account ahead of a healthy one is the very thing the bands exist to prevent.
4596
+ */
4597
+ isStageEligible(account, bands) {
4598
+ return bands.ready.includes(account);
4599
+ }
4348
4600
  /** How the pool currently spreads requests. Shown on the card. */
4349
4601
  currentDistribution() {
4350
4602
  return this.distribution;
@@ -5103,6 +5355,17 @@ const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-xdpool/automation/run";
5103
5355
  /** Set or clear one account's reserved-credit floor. */
5104
5356
  const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-xdpool/accounts/credit-reserve";
5105
5357
  /**
5358
+ * Estimated credit spend, per channel and per model.
5359
+ *
5360
+ * Derived from the pack's own token ledger, never from a credit counter — the pack
5361
+ * records tokens, and the vendor's balance is only visible when it is read again. The
5362
+ * arithmetic (tokens ÷ 1e6 × the model's published multiplier) and the honesty about
5363
+ * its error live in `credits.ts`.
5364
+ *
5365
+ * `?days=N` limits the window; the ledger keeps 90 days.
5366
+ */
5367
+ const POOL_CREDITS_SPEND_PATH = "/plugins/dsh-xdpool/credits/spend";
5368
+ /**
5106
5369
  * Name the account to try first, or hand the choice back to the strategy.
5107
5370
  *
5108
5371
  * A route rather than a settings write for the reason the ignore list is a file:
@@ -5422,6 +5685,22 @@ const REGISTERED_CHANNELS = [
5422
5685
  note: "手机号注册即送免费额度;每日积分由服务端自动发放,登录奖励需在「新手任务」领取",
5423
5686
  login: "browser",
5424
5687
  smsHint: true,
5688
+ /**
5689
+ * `credits: {}` is deliberate and does NOT mean "nothing to collect".
5690
+ *
5691
+ * Raccoon pays two different things and they arrive two different ways:
5692
+ *
5693
+ * - the DAILY points are credited by the server on its own schedule, so
5694
+ * there is no check-in call to make. `credits.claimAll` answers "不支持每日
5695
+ * 签到" for this channel, and that answer is correct;
5696
+ * - the DESKTOP LOGIN BONUS is a one-off, per account, and it is collectable
5697
+ * — through `onboarding.claim`, not through the daily claim.
5698
+ *
5699
+ * The empty object is what keeps the daily "Claim credits" button off this card
5700
+ * (pressing it would only ever print that refusal). The login bonus has its own
5701
+ * button, which the card draws for the channels in `ONBOARDING_CHANNELS`.
5702
+ * `credits` staying present keeps the balance row on the card.
5703
+ */
5425
5704
  credits: {}
5426
5705
  },
5427
5706
  {
@@ -18270,6 +18549,29 @@ function registerPoolStatusRoute(ctx, deps) {
18270
18549
  }
18271
18550
  });
18272
18551
  /**
18552
+ * Estimated credit spend, per channel.
18553
+ *
18554
+ * GET, `?days=N` optional. Read-only and cheap: the ledger roll-up is arithmetic
18555
+ * over a file the pack already keeps, so the strip can refresh on every turn
18556
+ * without touching an upstream.
18557
+ */
18558
+ const disposeCreditsSpend = ctx.webServer.register({
18559
+ kind: "exact",
18560
+ path: POOL_CREDITS_SPEND_PATH,
18561
+ handler: async (req, res) => {
18562
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
18563
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
18564
+ if (deps.creditsSpend === void 0) return json(res, 503, { error: "credit accounting is not available in this build" });
18565
+ try {
18566
+ const raw = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("days");
18567
+ const parsed = raw === null ? void 0 : Number(raw);
18568
+ json(res, 200, await deps.creditsSpend(parsed === void 0 || !Number.isFinite(parsed) ? void 0 : Math.trunc(parsed)));
18569
+ } catch (error) {
18570
+ json(res, 500, { error: safeMessage(error) });
18571
+ }
18572
+ }
18573
+ });
18574
+ /**
18273
18575
  * Replace the whole chain table.
18274
18576
  *
18275
18577
  * POST only, one table per request (see `POOL_COMBOS_SAVE_PATH`). The write
@@ -18325,6 +18627,7 @@ function registerPoolStatusRoute(ctx, deps) {
18325
18627
  disposeRelay();
18326
18628
  disposeCombosSave();
18327
18629
  disposeCombos();
18630
+ disposeCreditsSpend();
18328
18631
  disposeChannels();
18329
18632
  disposeAggregate();
18330
18633
  disposeAggregateDiscover();
@@ -20668,6 +20971,328 @@ async function recoverFromContextOverrun(options) {
20668
20971
  };
20669
20972
  }
20670
20973
  //#endregion
20974
+ //#region src/credits.ts
20975
+ /**
20976
+ * Coefficients by model id, as the billing gateway names them.
20977
+ *
20978
+ * Keys are lower-cased and matched case-insensitively, because the three sources that
20979
+ * name a model here (the pool catalog, the pack ledger, a channel listing) disagree on
20980
+ * casing: `deepseek-v4.1-flash` / `DeepSeek-V4.1-Flash`.
20981
+ *
20982
+ * The `(闲时)` suffix in the source table marks DeepSeek's off-peak price. It is used as
20983
+ * the plain rate: peak pricing is higher, so an estimate built on the off-peak figure
20984
+ * UNDERSTATES the bill, which is the safe direction for a "can I keep going" gauge.
20985
+ */
20986
+ const RATE_TABLE = {
20987
+ "deepseek-v4.1-flash": {
20988
+ input: 14.29,
20989
+ cache: .29,
20990
+ output: 57.1
20991
+ },
20992
+ "space-bunny": {
20993
+ input: 15,
20994
+ cache: 3,
20995
+ output: 60
20996
+ },
20997
+ "minimax-m3.1-flash": {
20998
+ input: 28.57,
20999
+ cache: 2.86,
21000
+ output: 114.3
21001
+ },
21002
+ "glm-5.3-flash": {
21003
+ input: 14.86,
21004
+ cache: 4.29,
21005
+ output: 52
21006
+ },
21007
+ "mimo-v2.6-pro": {
21008
+ input: 85.71,
21009
+ cache: .71,
21010
+ output: 171.4
21011
+ },
21012
+ "hy4-preview": {
21013
+ input: 142.86,
21014
+ cache: 5.71,
21015
+ output: 285.7
21016
+ },
21017
+ "step-5-preview": {
21018
+ input: 120,
21019
+ cache: 12,
21020
+ output: 342.9
21021
+ },
21022
+ "deepseek-v4-pro": {
21023
+ input: 257.14,
21024
+ cache: 8.57,
21025
+ output: 771.4
21026
+ },
21027
+ "glm-5.3": {
21028
+ input: 160,
21029
+ cache: 40,
21030
+ output: 560
21031
+ },
21032
+ "glm-5.2": {
21033
+ input: 160,
21034
+ cache: 40,
21035
+ output: 560
21036
+ },
21037
+ "kimi-k2.8-preview": {
21038
+ input: 158,
21039
+ cache: 41.43,
21040
+ output: 655.7
21041
+ },
21042
+ "kimi-k3.1-pro": {
21043
+ input: 315.71,
21044
+ cache: 31.71,
21045
+ output: 1651.4
21046
+ },
21047
+ "kimi-k3-1": {
21048
+ input: 485.71,
21049
+ cache: 48.57,
21050
+ output: 2428.6
21051
+ },
21052
+ "kimi-k3": {
21053
+ input: 485.71,
21054
+ cache: 48.57,
21055
+ output: 2428.6
21056
+ }
21057
+ };
21058
+ /**
21059
+ * Tokens are frequently reported in a "total" form that already includes the cached
21060
+ * portion, while the pool's own ledger keeps them apart. Normalising here — rather than
21061
+ * at each call site — is what keeps one caller from double-counting.
21062
+ */
21063
+ function cacheRateOf(model) {
21064
+ const key = model.trim().toLowerCase();
21065
+ if (key === "") return void 0;
21066
+ const direct = RATE_TABLE[key];
21067
+ if (direct !== void 0) return direct;
21068
+ const tail = key.split("/").pop();
21069
+ if (tail !== void 0 && tail !== key) {
21070
+ const byTail = RATE_TABLE[tail];
21071
+ if (byTail !== void 0) return byTail;
21072
+ }
21073
+ for (const candidate of Object.keys(RATE_TABLE)) if (key.startsWith(candidate) || key.endsWith(candidate)) return RATE_TABLE[candidate];
21074
+ }
21075
+ /**
21076
+ * Credits for one row, using the three-coefficient formula.
21077
+ *
21078
+ * Each term is divided by a million because the coefficients are per-million-token; the
21079
+ * sum is rounded to four decimals so a long tail of small requests accumulates into
21080
+ * something comparable without claiming precision nobody has.
21081
+ */
21082
+ function creditsOf(inputTokens, cacheTokens, outputTokens, rates) {
21083
+ if (rates === void 0) return void 0;
21084
+ const fresh = Math.max(0, inputTokens);
21085
+ const cached = Math.max(0, cacheTokens);
21086
+ const out = Math.max(0, outputTokens);
21087
+ const total = (fresh * rates.input + cached * rates.cache + out * rates.output) / 1e6;
21088
+ return Math.round(total * 1e4) / 1e4;
21089
+ }
21090
+ /** Roll per-model rows up to one line per channel. */
21091
+ function totalsByChannel(rows) {
21092
+ const byChannel = /* @__PURE__ */ new Map();
21093
+ for (const row of rows) {
21094
+ const current = byChannel.get(row.channel) ?? {
21095
+ channel: row.channel,
21096
+ requests: 0,
21097
+ inputTokens: 0,
21098
+ cacheTokens: 0,
21099
+ outputTokens: 0,
21100
+ credits: 0,
21101
+ unpricedModels: 0
21102
+ };
21103
+ current.requests += row.requests;
21104
+ current.inputTokens += row.inputTokens;
21105
+ current.cacheTokens += row.cacheTokens;
21106
+ current.outputTokens += row.outputTokens;
21107
+ if (row.credits === void 0) current.unpricedModels += 1;
21108
+ else current.credits = Math.round((current.credits + row.credits) * 1e4) / 1e4;
21109
+ byChannel.set(row.channel, current);
21110
+ }
21111
+ return [...byChannel.values()].sort((a, b) => b.credits - a.credits);
21112
+ }
21113
+ /**
21114
+ * The coefficients for a model, exported for the report.
21115
+ *
21116
+ * Returns undefined rather than zeros for an unknown model: "we do not know the price"
21117
+ * and "this was free" are different statements, and a zero would collapse them.
21118
+ */
21119
+ function ratesFor(model) {
21120
+ return cacheRateOf(model);
21121
+ }
21122
+ //#endregion
21123
+ //#region src/credit-report.ts
21124
+ /**
21125
+ * Read the pack's token ledger off disk and estimate credit spend per channel.
21126
+ *
21127
+ * WHY IT READS THE FILE INSTEAD OF CALLING THE PACK
21128
+ *
21129
+ * `usage.tokenLedgerHistory` exists, but it is reachable only through the pack's
21130
+ * browser-facing RPC (`/api/channel-pack`), which the host half does not speak — that
21131
+ * route is installed by the pack for the CARD. Reading `token-ledger.json` directly is
21132
+ * cheaper, needs no round trip, and works while the pack is still mounting.
21133
+ *
21134
+ * The file is a build artifact of another repository, so its shape is treated as
21135
+ * external and every level is validated before use: an unexpected shape must degrade
21136
+ * to "no data", never to a wrong number. The only thing relied on is the documented
21137
+ * shape — `{ days: { 'YYYY-MM-DD': { '<mode>|<channel>|<account>|<model>': stats } } }`
21138
+ * — and each key component is parsed positionally with a length check.
21139
+ *
21140
+ * WHAT IT DOES NOT DO
21141
+ *
21142
+ * It does not claim to know what the vendor charged. See `credits.ts` for the
21143
+ * arithmetic and for why the figure is labelled "estimated".
21144
+ *
21145
+ * @module dsh-xdpool/credit-report
21146
+ */
21147
+ /**
21148
+ * Which ledger rows count as "the account pool spent this".
21149
+ *
21150
+ * The pack stamps every row with a `channel` of exactly two values:
21151
+ *
21152
+ * - `direct` — the pool (or a channel) was used by this plugin itself, which is the
21153
+ * DSH conversation spending pooled quota. This is what the strip reports;
21154
+ * - `gateway` — the request arrived through the LAN relay, i.e. some OTHER device
21155
+ * using this machine as an OpenAI endpoint.
21156
+ *
21157
+ * Mixing them would make the strip answer a question nobody asked — "how much has my
21158
+ * whole household spent" — and would let a phone pointed at the relay inflate a number
21159
+ * the user reads as their own conversation. They are counted separately.
21160
+ */
21161
+ const POOL_MODE = "direct";
21162
+ /**
21163
+ * Where the ledger lives, relative to the DSH home.
21164
+ *
21165
+ * Under `channel-pack/`, not at the top of the home — the pack owns its own directory
21166
+ * and this file is one of its artifacts. Reading `home/token-ledger.json` finds
21167
+ * nothing and reports an empty ledger, which looks exactly like "you have spent
21168
+ * nothing" rather than "this is looking in the wrong place".
21169
+ */
21170
+ const LEDGER_FILE = join("channel-pack", "token-ledger.json");
21171
+ function numberOf(value) {
21172
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
21173
+ }
21174
+ /** `2026-11-07` minus N days, as the same string shape. */
21175
+ function daysAgoKey(days, now = /* @__PURE__ */ new Date()) {
21176
+ const copy = new Date(now.getTime());
21177
+ copy.setUTCDate(copy.getUTCDate() - days);
21178
+ return copy.toISOString().slice(0, 10);
21179
+ }
21180
+ /** Split `mode|channel|account|model`, refusing anything that is not four parts. */
21181
+ function parseLedgerKey(key) {
21182
+ const parts = key.split("|");
21183
+ if (parts.length < 4) return void 0;
21184
+ const mode = (parts[0] ?? "").trim();
21185
+ const channel = (parts[1] ?? "").trim();
21186
+ const model = (parts[3] ?? "").trim();
21187
+ if (mode === "" || channel === "" || model === "") return void 0;
21188
+ return {
21189
+ mode,
21190
+ channel,
21191
+ model
21192
+ };
21193
+ }
21194
+ /**
21195
+ * Build the report.
21196
+ *
21197
+ * `rateOf` resolves a model's multiplier from the channel's catalogue. It is a
21198
+ * callback rather than a table because the catalogue is the pack's live data — reading
21199
+ * it here would mean a second copy that drifts. Returning undefined for a model means
21200
+ * "no published rate", which the report counts separately instead of billing at zero.
21201
+ */
21202
+ async function readCreditSpend(input) {
21203
+ const empty = {
21204
+ windowDays: input.days,
21205
+ from: "",
21206
+ to: "",
21207
+ rows: [],
21208
+ channels: [],
21209
+ unreadable: 0,
21210
+ gatewayRequests: 0,
21211
+ empty: true
21212
+ };
21213
+ let text;
21214
+ try {
21215
+ text = await readFile(join(input.home, LEDGER_FILE), "utf8");
21216
+ } catch {
21217
+ return empty;
21218
+ }
21219
+ let parsed;
21220
+ try {
21221
+ parsed = JSON.parse(text);
21222
+ } catch {
21223
+ return empty;
21224
+ }
21225
+ const daysNode = parsed.days;
21226
+ if (daysNode === null || typeof daysNode !== "object") return empty;
21227
+ const earliest = input.days === void 0 ? void 0 : daysAgoKey(input.days, input.now);
21228
+ const acc = /* @__PURE__ */ new Map();
21229
+ let unreadable = 0;
21230
+ let gatewayRequests = 0;
21231
+ let sawAnyDay = false;
21232
+ let from = "";
21233
+ let to = "";
21234
+ for (const [day, entries] of Object.entries(daysNode)) {
21235
+ if (/^\d{4}-\d{2}-\d{2}$/u.test(day)) {
21236
+ if (earliest !== void 0 && day < earliest) continue;
21237
+ sawAnyDay = true;
21238
+ if (from === "" || day < from) from = day;
21239
+ if (to === "" || day > to) to = day;
21240
+ } else continue;
21241
+ if (entries === null || typeof entries !== "object") continue;
21242
+ for (const [key, stats] of Object.entries(entries)) {
21243
+ const named = parseLedgerKey(key);
21244
+ if (named === void 0) {
21245
+ unreadable += 1;
21246
+ continue;
21247
+ }
21248
+ if (named.mode !== POOL_MODE) {
21249
+ const entry = stats;
21250
+ if (entry !== null && typeof entry === "object") gatewayRequests += numberOf(entry.requests);
21251
+ continue;
21252
+ }
21253
+ if (stats === null || typeof stats !== "object") continue;
21254
+ const entry = stats;
21255
+ const id = `${named.channel}\u0000${named.model}`;
21256
+ const current = acc.get(id) ?? {
21257
+ channel: named.channel,
21258
+ model: named.model,
21259
+ requests: 0,
21260
+ inputTokens: 0,
21261
+ cacheTokens: 0,
21262
+ outputTokens: 0
21263
+ };
21264
+ current.requests += numberOf(entry.requests);
21265
+ current.inputTokens += numberOf(entry.inputTokens);
21266
+ current.cacheTokens += numberOf(entry.cacheReadTokens);
21267
+ current.outputTokens += numberOf(entry.outputTokens);
21268
+ acc.set(id, current);
21269
+ }
21270
+ }
21271
+ const rows = [...acc.values()].map((entry) => {
21272
+ const rates = input.rateOf(entry.channel, entry.model);
21273
+ return {
21274
+ channel: entry.channel,
21275
+ model: entry.model,
21276
+ requests: entry.requests,
21277
+ inputTokens: entry.inputTokens,
21278
+ cacheTokens: entry.cacheTokens,
21279
+ outputTokens: entry.outputTokens,
21280
+ rates,
21281
+ credits: creditsOf(entry.inputTokens, entry.cacheTokens, entry.outputTokens, rates)
21282
+ };
21283
+ }).sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens));
21284
+ return {
21285
+ windowDays: input.days,
21286
+ from: sawAnyDay ? from : "",
21287
+ to: sawAnyDay ? to : "",
21288
+ rows,
21289
+ channels: totalsByChannel(rows),
21290
+ unreadable,
21291
+ gatewayRequests,
21292
+ empty: !sawAnyDay
21293
+ };
21294
+ }
21295
+ //#endregion
20671
21296
  //#region src/relay.ts
20672
21297
  /**
20673
21298
  * LAN relay: re-exposes the pool's loopback OpenAI shim on a LAN address, so a
@@ -20802,6 +21427,28 @@ function writeJson(res, status, code, message) {
20802
21427
  res.end(body);
20803
21428
  }
20804
21429
  /**
21430
+ * Turn a bind failure into something the reader can act on.
21431
+ *
21432
+ * `listen EADDRINUSE: address already in use 127.0.0.1:8326` is accurate and useless.
21433
+ * The overwhelmingly common cause is not "another program stole the port" — it is a
21434
+ * previous DSH that has not finished exiting, still holding the relay it was serving.
21435
+ * The requested port and the old listener are the same, so the fix is either to wait,
21436
+ * or to pick a different port, and the message should say both.
21437
+ *
21438
+ * `EADDRNOTAVAIL` gets its own line because it means the machine has no such
21439
+ * interface, which is a different mistake from a busy port and sends the reader
21440
+ * looking in the wrong place if merged.
21441
+ *
21442
+ * The raw text is kept at the end rather than dropped: it is the only thing that can
21443
+ * be searched for, and a paraphrase that loses it is worse than no paraphrase.
21444
+ */
21445
+ function explainBindFailure(raw, host, port) {
21446
+ if (/EADDRINUSE/iu.test(raw)) return `端口 ${port} 已被占用(${host})。最常见的原因是上一个 DSH 还没完全退出、仍占着这个网关端口:稍等几秒重试,或换一个端口后再打开。原始错误:${raw}`;
21447
+ if (/EADDRNOTAVAIL/iu.test(raw)) return `本机没有这个绑定地址(${host})。选 127.0.0.1 只允许本机访问;要让手机等其他设备连进来才选 0.0.0.0。原始错误:${raw}`;
21448
+ if (/EACCES/iu.test(raw)) return `没有权限绑定端口 ${port}。1024 以下的端口通常需要管理员权限,换一个 1024 以上的端口即可。原始错误:${raw}`;
21449
+ return raw;
21450
+ }
21451
+ /**
20805
21452
  * Build the relay.
20806
21453
  *
20807
21454
  * Nothing binds until {@link PoolRelay.ready} settles, so a port clash becomes a
@@ -20845,8 +21492,9 @@ function createPoolRelay(options) {
20845
21492
  const start = (host, port) => new Promise((resolve) => {
20846
21493
  const next = createServer(handle);
20847
21494
  const fail = (error) => {
20848
- bindError = error instanceof Error ? error.message : String(error);
20849
- logger?.warn(`xdpool relay: cannot bind ${host}:${port} — ${bindError}`);
21495
+ const raw = error instanceof Error ? error.message : String(error);
21496
+ bindError = explainBindFailure(raw, host, port);
21497
+ logger?.warn(`xdpool relay: cannot bind ${host}:${port} — ${raw}`);
20850
21498
  next.close(() => resolve());
20851
21499
  };
20852
21500
  next.once("error", fail);
@@ -22145,6 +22793,67 @@ function apply(ctx, config = {}) {
22145
22793
  roster: () => shim.roster(),
22146
22794
  warn: (error) => ctx.logger.warn("dsh-xdpool: gateway roster failed", error)
22147
22795
  });
22796
+ /**
22797
+ * Estimated credit spend, read from the pack's own token ledger.
22798
+ *
22799
+ * TWO rate sources, because the pool and the login channels price models
22800
+ * differently and NEITHER publishes a rate through the ledger:
22801
+ *
22802
+ * - a POOL model (`buddy`, `workbuddy`) carries `multiplier` on its catalog row,
22803
+ * which is the gateway's own `creditMultiplier`. The built-in fallback table
22804
+ * deliberately has none — a stale price is worse than no price — so this is
22805
+ * populated only once the live catalog has loaded;
22806
+ * - a LOGIN-CHANNEL model (`trae`, `zcode`, …) carries its rate in the DISPLAY NAME
22807
+ * the pack builds (`Hy3 · x0.05`), because `model.list` has no rate field at all.
22808
+ *
22809
+ * Checking only the second was wrong: it left every pool row unpriced, which reads as
22810
+ * "the pool is free" — the opposite of the truth for the channel the user actually
22811
+ * uses most.
22812
+ */
22813
+ /**
22814
+ * Estimated credit spend, read from the pack's own token ledger.
22815
+ *
22816
+ * The coefficients come from the table in `credits.ts`, measured against the live
22817
+ * gateway — NOT from the `x0.11` figure the picker displays. That figure is a label;
22818
+ * billing is three separate per-model coefficients (fresh input / cache read / output),
22819
+ * and using the label as a flat rate understated a normal day by two orders of
22820
+ * magnitude because it ignored the cache tier entirely.
22821
+ *
22822
+ * The pool's own catalog carries a `multiplier`, and it is deliberately NOT used for
22823
+ * money: it is whatever the vendor chose to display, it moves with promotions, and the
22824
+ * three-coefficient table is the only thing that reconciles against real charges.
22825
+ */
22826
+ const creditSpend = async (days) => {
22827
+ /**
22828
+ * Channel model names, read from the pack's own listing — NOT from the gateway
22829
+ * roster.
22830
+ *
22831
+ * `hostRoster()` returns only what the gateway SHARES, and a user typically shares
22832
+ * one model. The ledger records every model the pool has ever served, so pricing off
22833
+ * the roster left almost every channel row unpriced.
22834
+ */
22835
+ let channelNames;
22836
+ const rateOf = (channel, modelId) => {
22837
+ const direct = ratesFor(modelId);
22838
+ if (direct !== void 0) return direct;
22839
+ const row = channelNames?.find((candidate) => candidate.channel === channel && candidate.modelId === modelId);
22840
+ return row === void 0 ? void 0 : ratesFor(row.name);
22841
+ };
22842
+ try {
22843
+ channelNames = (await channelLane.rows()).map((row) => ({
22844
+ channel: row.channel,
22845
+ modelId: row.modelId,
22846
+ name: row.name
22847
+ }));
22848
+ } catch (error) {
22849
+ ctx.logger?.warn?.("dsh-xdpool: channel model names unavailable for pricing:", error);
22850
+ }
22851
+ return await readCreditSpend({
22852
+ home: dshHome(),
22853
+ days,
22854
+ rateOf
22855
+ });
22856
+ };
22148
22857
  ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
22149
22858
  pool: core.pool,
22150
22859
  catalogs: core.catalogs,
@@ -22344,6 +23053,8 @@ function apply(ctx, config = {}) {
22344
23053
  await aggregateCatalog.refresh();
22345
23054
  return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current(), await hostRoster());
22346
23055
  },
23056
+ /** Estimated credit spend per channel, for the composer strip. */
23057
+ creditsSpend: async (days) => await creditSpend(days),
22347
23058
  /**
22348
23059
  * Relay state plus the gateway's model picker.
22349
23060
  *