dsh-xdpool 2.5.1 → 2.6.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/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 { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
+ import { chmod, mkdir, readFile, readdir, rename, rm, 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";
@@ -168,24 +168,53 @@ function isAbortError(error) {
168
168
  }
169
169
  /** Cap on the reassembled compaction reply, guarding against a runaway stream. */
170
170
  const COMPLETION_TEXT_LIMIT = 65536;
171
- /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
171
+ /**
172
+ * Insufficient-credit markers, ASCII lowercase plus the original Chinese.
173
+ *
174
+ * The PLURAL forms are listed separately on purpose: this table is matched with
175
+ * `includes`, so `'credits exhausted'.includes('credit exhausted')` is false.
176
+ * The gateway's own 14018 message is `Credits exhausted.` — plural — which
177
+ * therefore matched nothing, fell through to `soft_rate`, and cooled the account
178
+ * and rotated instead of parking it. A rate-limit cooldown on a drained account
179
+ * means every model on it keeps being retried and keeps failing (#from the
180
+ * connect-workbuddy cross-check).
181
+ */
172
182
  const HARD_CREDIT_MARKERS = [
173
183
  "insufficient credit",
184
+ "insufficient credits",
174
185
  "no credit",
186
+ "no credits",
175
187
  "credit exhausted",
188
+ "credits exhausted",
189
+ "credit exhausted.",
176
190
  "out of credit",
191
+ "out of credits",
177
192
  "quota exceeded",
178
193
  "quota exhaust",
194
+ "quota exhausted",
179
195
  "payment required",
180
196
  "credit not enough",
197
+ "credits not enough",
181
198
  "not enough credit",
199
+ "not enough credits",
182
200
  "积分不足",
183
201
  "额度不足",
184
202
  "余额不足",
185
203
  "积分用完",
186
204
  "额度用尽",
187
- "没有积分"
205
+ "没有积分",
206
+ "额度耗尽",
207
+ "积分耗尽"
188
208
  ];
209
+ /**
210
+ * Business codes that mean "this account cannot pay", independent of wording.
211
+ *
212
+ * `14018` is the gateway's credits-exhausted code; the number survived several
213
+ * rewordings of the sentence around it, so matching it is steadier than matching
214
+ * prose. Read from the body rather than from the HTTP status because the gateway
215
+ * answers these inside a 200 or a 4xx envelope.
216
+ */
217
+ const HARD_CREDIT_CODES = ["14018", "14003"];
189
218
  /** Session-invalidation markers that mean "this credential is dead; use another".
190
219
  * Kept alongside the HTTP-status rule in `classifyUpstreamError`: the status is
191
220
  * enough for a direct 401/403, but some failures arrive wrapped in a 200
@@ -430,7 +459,10 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
430
459
  const BANNED_SYSTEM_PHRASES = Object.freeze([
431
460
  [/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"],
432
461
  [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"],
433
- [/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"]
462
+ [/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"],
463
+ [/You are a coding agent running in the Codex CLI, a terminal-based coding assistant/gi, "You are a coding agent running in this environment, a terminal-based coding assistant"],
464
+ [/You are a coding agent running in the Codex CLI/gi, "You are a coding agent running in this environment"],
465
+ [/Codex CLI is an open source project led by OpenAI/gi, "This coding agent is an open source project"]
434
466
  ]);
435
467
  /**
436
468
  * Rewrite fingerprinted system/assistant content, leaving everything else
@@ -740,6 +772,7 @@ function classifyUpstreamError(status, body) {
740
772
  if (status === 402) return "hard_credit";
741
773
  if (status === 401 || status === 403) return "session_dead";
742
774
  const lower = body.toLowerCase();
775
+ for (const code of HARD_CREDIT_CODES) if (body.includes(`"code":${code}`) || body.includes(`"code": ${code}`) || body.includes(`"code":"${code}"`)) return "hard_credit";
743
776
  for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
744
777
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
745
778
  if (status === 429) return "soft_rate";
@@ -1524,14 +1557,17 @@ var WorkBuddyUpstreamClient = class {
1524
1557
  if (typeof raw !== "object" || raw === null) continue;
1525
1558
  const account = raw;
1526
1559
  const num = (key) => typeof account[key] === "number" ? account[key] : 0;
1527
- const monthly = num("CapacityType") === 4;
1528
- const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
1529
- const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
1560
+ const capacityType = num("CapacityType");
1561
+ const monthly = capacityType === 4;
1562
+ const periodic = monthly || capacityType === 2;
1563
+ const size = periodic ? num("CycleCapacitySize") : num("CapacitySize");
1564
+ const remain = periodic ? num("CycleCapacityRemain") : num("CapacityRemain");
1530
1565
  const capped = remain < 0 ? 0 : remain;
1531
1566
  const cycleEndMs = parseDate(account["CycleEndTime"]);
1532
- const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1567
+ const expiresAtMs = monthly ? void 0 : capacityType === 2 ? parseDate(account["DeductionEndTime"]) : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1533
1568
  const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
1534
- if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
1569
+ if (expiresAtMs !== void 0 && expiresAtMs <= Date.now()) continue;
1570
+ if (!periodic && capped <= 0) continue;
1535
1571
  total += capped;
1536
1572
  if (expiresAtMs !== void 0) {
1537
1573
  if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
@@ -2262,6 +2298,92 @@ var WorkBuddyUpstreamClient = class {
2262
2298
  }
2263
2299
  };
2264
2300
  //#endregion
2301
+ //#region src/file-perms.ts
2302
+ /**
2303
+ * Write a file that holds credentials, without leaving it world-readable.
2304
+ *
2305
+ * Every credential this plugin keeps on disk — the durable account list, the
2306
+ * ignore list, any mirrored balance — travelled through a plain `writeFile` with
2307
+ * the process umask as the only protection. On a POSIX host that typically means
2308
+ * `0644`: readable by every other user on the machine, and these files carry
2309
+ * `accessToken` / `refreshToken` verbatim.
2310
+ *
2311
+ * Two rules, and the order matters:
2312
+ *
2313
+ * 1. **Create, don't patch.** `mode` on `writeFile` only applies when the file is
2314
+ * created. The atomic-write pattern already writes a fresh temp file and
2315
+ * renames it over the target, so the temp is created with `0600` and the
2316
+ * rename carries that permission to the destination. Chmod'ing afterwards
2317
+ * would also work but leaves a window where the file exists with the old
2318
+ * mode.
2319
+ * 2. **Still chmod best-effort.** A file written by an older version already
2320
+ * exists with `0644`; the rename replaces the inode, so rule 1 covers it —
2321
+ * but a host that keeps the target in place (or a future caller that writes
2322
+ * directly) would not be covered, and `chmod` is cheap.
2323
+ *
2324
+ * WINDOWS: `mode` is ignored by Node on win32, and there is no ACL equivalent in
2325
+ * the standard library. The honest statement is what actually protects these
2326
+ * files there: they live under the user's own profile (%USERPROFILE%, %LOCALAPPDATA%),
2327
+ * whose inherited ACL excludes other standard users by default. Calling `icacls`
2328
+ * per write would spawn a process on every balance update to re-apply a
2329
+ * protection the parent directory already provides, so this module does not. A
2330
+ * profile relocated to a shared directory is outside what this code can detect —
2331
+ * the README says where the files live so the user can check.
2332
+ *
2333
+ * @module dsh-xdpool/file-perms
2334
+ */
2335
+ /** Owner-only, for a file that holds a secret. */
2336
+ const SECRET_FILE_MODE = 384;
2337
+ /** Owner-only, for the directory that holds them. */
2338
+ const SECRET_DIR_MODE = 448;
2339
+ /**
2340
+ * True on a platform where POSIX modes are meaningful.
2341
+ *
2342
+ * Computed through a `typeof process` guard rather than read straight off the
2343
+ * global: this module is host-only in intent, but `store.ts` imports it and the
2344
+ * browser bundle imports `store.ts` for its row types, so the module body DOES
2345
+ * get bundled for the browser. A bare `process.platform` there throws
2346
+ * `ReferenceError: process is not defined` at first render — and it is the kind
2347
+ * of failure that only shows up in the packaged app.
2348
+ */
2349
+ const POSIX = typeof process === "undefined" ? false : process.platform !== "win32";
2350
+ /**
2351
+ * Create the directory a secret lives in, owner-only.
2352
+ *
2353
+ * `recursive` so a fresh install works, and the mode applies to every directory
2354
+ * this call creates. An existing directory keeps its mode — `mkdir` does not
2355
+ * change what is already there — so `chmod` runs too, best-effort: tightening a
2356
+ * directory that a previous version created permissively is exactly the case
2357
+ * worth fixing, and a failure is not worth aborting a save over.
2358
+ */
2359
+ async function ensureSecretDir(dir) {
2360
+ await mkdir(dir, {
2361
+ recursive: true,
2362
+ ...POSIX ? { mode: SECRET_DIR_MODE } : {}
2363
+ });
2364
+ if (!POSIX) return;
2365
+ try {
2366
+ await chmod(dir, SECRET_DIR_MODE);
2367
+ } catch {}
2368
+ }
2369
+ /**
2370
+ * Write `contents` to `path` with owner-only permissions.
2371
+ *
2372
+ * Not atomic on its own — callers that need the crash-safe temp+rename dance keep
2373
+ * doing it and just route the temp write through here, which is why the mode is
2374
+ * applied at creation: the rename then carries it to the final file.
2375
+ */
2376
+ async function writeSecretFile(path, contents) {
2377
+ await writeFile(path, contents, POSIX ? {
2378
+ encoding: "utf8",
2379
+ mode: SECRET_FILE_MODE
2380
+ } : "utf8");
2381
+ if (!POSIX) return;
2382
+ try {
2383
+ await chmod(path, SECRET_FILE_MODE);
2384
+ } catch {}
2385
+ }
2386
+ //#endregion
2265
2387
  //#region src/ignored.ts
2266
2388
  /**
2267
2389
  * The permanent ignore list: accounts the user has thrown out of the pool.
@@ -2378,9 +2500,9 @@ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
2378
2500
  version: 1,
2379
2501
  accounts
2380
2502
  }, null, 2);
2381
- await mkdir(dirname(path), { recursive: true });
2503
+ await ensureSecretDir(dirname(path));
2382
2504
  const temp = `${path}.tmp`;
2383
- await writeFile(temp, `${payload}\n`, "utf8");
2505
+ await writeSecretFile(temp, `${payload}\n`);
2384
2506
  await rename(temp, path);
2385
2507
  }
2386
2508
  /**
@@ -2506,9 +2628,9 @@ async function saveStoredAccounts(path, accounts) {
2506
2628
  version: 1,
2507
2629
  accounts: stored
2508
2630
  }, null, 2) + "\n";
2509
- await mkdir(join(path, ".."), { recursive: true });
2631
+ await ensureSecretDir(join(path, ".."));
2510
2632
  const tmp = `${path}.tmp`;
2511
- await writeFile(tmp, document, "utf8");
2633
+ await writeSecretFile(tmp, document);
2512
2634
  await rename(tmp, path);
2513
2635
  }
2514
2636
  //#endregion
@@ -3272,16 +3394,20 @@ function parseWorkBuddyAuth(text, sourcePath, decrypt) {
3272
3394
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs$1(auth["refreshExpiresAt"]) : void 0;
3273
3395
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
3274
3396
  const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs$1(auth["lastRefreshTime"]) : void 0;
3397
+ const nickname = optionalString(decryptableString(identity["nickname"], decrypt).value);
3398
+ const uin = optionalString(identity["uin"]);
3399
+ const uid = optionalString(identity["uid"]);
3400
+ const enterpriseId = optionalString(identity["enterpriseId"]);
3275
3401
  return {
3276
3402
  accessToken,
3277
3403
  refreshToken: decryptableString(auth["refreshToken"], decrypt).value,
3278
3404
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs$1(auth["expiresAt"]) : 0,
3279
3405
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
3280
3406
  ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
3281
- ...optionalString(decryptableString(identity["nickname"], decrypt).value) === void 0 ? {} : { nickname: optionalString(decryptableString(identity["nickname"], decrypt).value) },
3282
- ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
3283
- ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
3284
- ...optionalString(identity["enterpriseId"]) === void 0 ? {} : { enterpriseId: optionalString(identity["enterpriseId"]) },
3407
+ ...nickname === void 0 ? {} : { nickname },
3408
+ ...uin === void 0 ? {} : { uin },
3409
+ ...uid === void 0 ? {} : { uid },
3410
+ ...enterpriseId === void 0 ? {} : { enterpriseId },
3285
3411
  domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
3286
3412
  sourcePath
3287
3413
  };
@@ -3626,6 +3752,16 @@ var WorkBuddyAccountPool = class {
3626
3752
  */
3627
3753
  disabledIds = /* @__PURE__ */ new Set();
3628
3754
  /**
3755
+ * The account ids the store held after the last successful write.
3756
+ *
3757
+ * Read back on the next scan to say WHICH accounts left, not just how many.
3758
+ * The list is written wholesale, so a scan that does not find an account
3759
+ * deletes it — and that account's credential is then unrecoverable without a
3760
+ * fresh sign-in (#117). `undefined` until the first write, so the first scan
3761
+ * never reports every account as "leave".
3762
+ */
3763
+ lastPersistedIds;
3764
+ /**
3629
3765
  * Account ids the user threw out of the pool for good.
3630
3766
  *
3631
3767
  * Enforced BEFORE the credential is parsed: `scan()` skips a file whose
@@ -3702,6 +3838,11 @@ var WorkBuddyAccountPool = class {
3702
3838
  * A function rather than a list because that pool is mounted asynchronously
3703
3839
  * and can be re-read on demand; `undefined` means "no such source", which is
3704
3840
  * the state every test and every pre-mount startup is in.
3841
+ *
3842
+ * The `| undefined` is explicit because `applyExternalSource` CLEARS the field by
3843
+ * assigning `undefined` — under `exactOptionalPropertyTypes` a bare `?:` means
3844
+ * "may be absent, never undefined", which made that documented clearing action
3845
+ * a type error.
3705
3846
  */
3706
3847
  externalSource;
3707
3848
  constructor(options = {}) {
@@ -3807,9 +3948,10 @@ var WorkBuddyAccountPool = class {
3807
3948
  }
3808
3949
  const byId = /* @__PURE__ */ new Map();
3809
3950
  for (const account of this.accounts) byId.set(account.id, account);
3810
- if (this.persistent && this.accounts.length === 0) {
3951
+ if (this.persistent) {
3811
3952
  const stored = await loadStoredAccounts(this.storePath);
3812
3953
  for (const entry of stored) {
3954
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(entry.id)) continue;
3813
3955
  if (byId.has(entry.id)) continue;
3814
3956
  byId.set(entry.id, {
3815
3957
  id: entry.id,
@@ -3859,7 +4001,10 @@ var WorkBuddyAccountPool = class {
3859
4001
  this.accounts = ordered;
3860
4002
  this.lastScanAtMs = Date.now();
3861
4003
  if (this.persistent) try {
4004
+ const leaving = this.lastPersistedIds === void 0 ? [] : [...this.lastPersistedIds].filter((id) => !this.accounts.some((account) => account.id === id));
4005
+ if (leaving.length > 0) this.logger?.warn?.(`dsh-xdpool: ${String(leaving.length)} account(s) are no longer in the pool after a rescan and were removed from the store: ${leaving.join(", ")}. Their credentials are gone unless they are signed in again.`);
3862
4006
  await saveStoredAccounts(this.storePath, this.accounts);
4007
+ this.lastPersistedIds = new Set(this.accounts.map((account) => account.id));
3863
4008
  } catch (error) {
3864
4009
  this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
3865
4010
  }
@@ -4375,6 +4520,43 @@ var WorkBuddyAccountPool = class {
4375
4520
  return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
4376
4521
  }
4377
4522
  /**
4523
+ * Try to bring benched accounts back WITHOUT waiting out the cooldown.
4524
+ *
4525
+ * The cooldown is a guess about how long a problem lasts, and the guess is
4526
+ * usually wrong in one direction or the other: a token revoked by a password
4527
+ * change stays dead for the full window even after the user signs in again in
4528
+ * some other client, and a transient upstream hiccup benches a perfectly good
4529
+ * account for half an hour.
4530
+ *
4531
+ * So instead of only waiting, ask. For each benched account, one cheap
4532
+ * authenticated read: if it answers, the credential works and the account goes
4533
+ * back into rotation immediately. Nothing about the failure is trusted — only a
4534
+ * successful call clears it — and a probe that fails keeps the original
4535
+ * cooldown untouched (probing must never EXTEND a bench, or a broken endpoint
4536
+ * would keep every account locked out for as long as it stayed broken).
4537
+ *
4538
+ * `probe` is injected rather than imported so this class stays free of the
4539
+ * upstream client: the pool knows about accounts, not about HTTP.
4540
+ *
4541
+ * Returns the accounts it revived, for logging and for the run summary.
4542
+ */
4543
+ async reviveBenched(probe) {
4544
+ const benched = this.deadCredentials();
4545
+ if (benched.length === 0) return [];
4546
+ const revived = [];
4547
+ for (const account of benched) {
4548
+ try {
4549
+ await probe(account.credential);
4550
+ } catch {
4551
+ continue;
4552
+ }
4553
+ this.clearCredentialDead(account.id);
4554
+ revived.push(account);
4555
+ this.logger?.info?.(`dsh-xdpool: ${account.label} answered a probe; back in rotation without waiting out the cooldown`);
4556
+ }
4557
+ return revived;
4558
+ }
4559
+ /**
4378
4560
  * Refresh the account's access token when it is within the margin (or already
4379
4561
  * expired), in-flight de-duped per account. A failed refresh keeps the
4380
4562
  * existing token when it has not yet expired, so an unreachable refresh
@@ -4521,8 +4703,23 @@ ${new Date(until).toISOString()}
4521
4703
  * (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
4522
4704
  * authoritative while being wrong.
4523
4705
  *
4524
- * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
4525
- * which is what turns on the free badge.
4706
+ * NO PRICES AND NO PROMO BADGES HERE. This table is what the picker shows when
4707
+ * the startup fetch fails, and a multiplier captured months ago is a price the
4708
+ * user will budget against while it is quietly wrong. Two specific harms:
4709
+ *
4710
+ * - `multiplier: 0` renders the "free" badge, so a model that was free at
4711
+ * capture time keeps claiming to be free after the promotion ends;
4712
+ * - a non-zero multiplier renders next to the model, so a stale figure reads
4713
+ * as the current rate.
4714
+ *
4715
+ * `undefined` is the honest state: the card prints no rate and no badge, which
4716
+ * is exactly true — this build does not know the price. The live fetch fills
4717
+ * both in within a second of a working startup, and `catalogSource` already
4718
+ * tells the card to say the list is the built-in one.
4719
+ *
4720
+ * Windows, output ceilings and the `craft` capability tag stay: those are
4721
+ * properties of the model, not of a campaign, and they are what makes the list
4722
+ * usable offline at all.
4526
4723
  */
4527
4724
  const FALLBACK_WORKBUDDY_MODELS = [
4528
4725
  {
@@ -4539,8 +4736,7 @@ const FALLBACK_WORKBUDDY_MODELS = [
4539
4736
  contextWindow: 96e4,
4540
4737
  maxOutputTokens: 64e3,
4541
4738
  supportsImages: true,
4542
- multiplier: .29,
4543
- tags: ["craft", "badge:夜间免费:#FF0000"]
4739
+ tags: ["craft"]
4544
4740
  },
4545
4741
  {
4546
4742
  id: "hy3",
@@ -4548,40 +4744,35 @@ const FALLBACK_WORKBUDDY_MODELS = [
4548
4744
  contextWindow: 192e3,
4549
4745
  maxOutputTokens: 64e3,
4550
4746
  supportsImages: true,
4551
- multiplier: 0,
4552
- tags: ["craft", "badge:限时免费:#FF0000"]
4747
+ tags: ["craft"]
4553
4748
  },
4554
4749
  {
4555
4750
  id: "hy3-x",
4556
4751
  name: "Hy3",
4557
4752
  contextWindow: 192e3,
4558
4753
  maxOutputTokens: 64e3,
4559
- supportsImages: true,
4560
- multiplier: .05
4754
+ supportsImages: true
4561
4755
  },
4562
4756
  {
4563
4757
  id: "space-bunny",
4564
4758
  name: "Space-Bunny",
4565
4759
  contextWindow: 1e6,
4566
4760
  maxOutputTokens: 64e3,
4567
- supportsImages: true,
4568
- multiplier: .08
4761
+ supportsImages: true
4569
4762
  },
4570
4763
  {
4571
4764
  id: "deepseek-v4.1-flash",
4572
4765
  name: "Deepseek-V4.1-Flash",
4573
4766
  contextWindow: 1e6,
4574
4767
  maxOutputTokens: 128e3,
4575
- supportsImages: true,
4576
- multiplier: .11
4768
+ supportsImages: true
4577
4769
  },
4578
4770
  {
4579
4771
  id: "deepseek-v4-pro",
4580
4772
  name: "Deepseek-V4-Pro",
4581
4773
  contextWindow: 1e6,
4582
4774
  maxOutputTokens: 128e3,
4583
- supportsImages: true,
4584
- multiplier: .51
4775
+ supportsImages: true
4585
4776
  },
4586
4777
  {
4587
4778
  id: "deepseek-v4-flash",
@@ -4595,16 +4786,14 @@ const FALLBACK_WORKBUDDY_MODELS = [
4595
4786
  name: "GLM-5.3",
4596
4787
  contextWindow: 1e6,
4597
4788
  maxOutputTokens: 64e3,
4598
- supportsImages: true,
4599
- multiplier: .79
4789
+ supportsImages: true
4600
4790
  },
4601
4791
  {
4602
4792
  id: "glm-5.3-flash",
4603
4793
  name: "GLM-5.3-Flash",
4604
4794
  contextWindow: 1e6,
4605
4795
  maxOutputTokens: 131072,
4606
- supportsImages: true,
4607
- multiplier: .06
4796
+ supportsImages: true
4608
4797
  },
4609
4798
  {
4610
4799
  id: "glm-5.2",
@@ -4612,64 +4801,56 @@ const FALLBACK_WORKBUDDY_MODELS = [
4612
4801
  contextWindow: 1e6,
4613
4802
  maxOutputTokens: 64e3,
4614
4803
  supportsImages: true,
4615
- multiplier: .79,
4616
- tags: ["craft", "badge:夜间折扣:#1E90FF"]
4804
+ tags: ["craft"]
4617
4805
  },
4618
4806
  {
4619
4807
  id: "glm-5.1",
4620
4808
  name: "GLM-5.1",
4621
4809
  contextWindow: 2e5,
4622
4810
  maxOutputTokens: 48e3,
4623
- supportsImages: false,
4624
- multiplier: .79
4811
+ supportsImages: false
4625
4812
  },
4626
4813
  {
4627
4814
  id: "glm-5v-turbo",
4628
4815
  name: "GLM-5v-Turbo",
4629
4816
  contextWindow: 2e5,
4630
4817
  maxOutputTokens: 64e3,
4631
- supportsImages: true,
4632
- multiplier: .71
4818
+ supportsImages: true
4633
4819
  },
4634
4820
  {
4635
4821
  id: "kimi-k3-1",
4636
4822
  name: "Kimi-K3",
4637
4823
  contextWindow: 1e6,
4638
4824
  maxOutputTokens: 32e3,
4639
- supportsImages: true,
4640
- multiplier: 1.62
4825
+ supportsImages: true
4641
4826
  },
4642
4827
  {
4643
4828
  id: "kimi-k2.8-preview",
4644
4829
  name: "Kimi-K2.8-Preview",
4645
4830
  contextWindow: 1e6,
4646
4831
  maxOutputTokens: 64e3,
4647
- supportsImages: true,
4648
- multiplier: .77
4832
+ supportsImages: true
4649
4833
  },
4650
4834
  {
4651
4835
  id: "kimi-k2.7",
4652
4836
  name: "Kimi-K2.7-Code",
4653
4837
  contextWindow: 256e3,
4654
4838
  maxOutputTokens: 32e3,
4655
- supportsImages: true,
4656
- multiplier: .57
4839
+ supportsImages: true
4657
4840
  },
4658
4841
  {
4659
4842
  id: "kimi-k2.6",
4660
4843
  name: "Kimi-K2.6",
4661
4844
  contextWindow: 256e3,
4662
4845
  maxOutputTokens: 32e3,
4663
- supportsImages: true,
4664
- multiplier: .52
4846
+ supportsImages: true
4665
4847
  },
4666
4848
  {
4667
4849
  id: "minimax-m3",
4668
4850
  name: "MiniMax-M3",
4669
4851
  contextWindow: 512e3,
4670
4852
  maxOutputTokens: 64e3,
4671
- supportsImages: true,
4672
- multiplier: .25
4853
+ supportsImages: true
4673
4854
  }
4674
4855
  ];
4675
4856
  /**
@@ -4742,11 +4923,24 @@ var WorkBuddyCatalog = class {
4742
4923
  }
4743
4924
  /**
4744
4925
  * The models DSH should actually offer, after applying the user's selection:
4745
- * disabled models are dropped, an explicit image list overrides the upstream
4746
- * capability flag, and a per-model budget caps the advertised window.
4926
+ * disabled models are dropped, an explicit image list decides image support,
4927
+ * and a per-model budget caps the advertised window.
4747
4928
  *
4748
4929
  * An absent `enabledModelIds` means "everything" — a fresh install with no
4749
4930
  * saved selection must not present an empty picker.
4931
+ *
4932
+ * IMAGE SUPPORT IS THE USER'S SET, NOT AN OR. The upstream flag is `true` for
4933
+ * essentially every model of this vendor (measured: 15 of 16 on CN, 23 of 23
4934
+ * on international), so `supportsImages || imageSet.has(id)` could only ever
4935
+ * turn the flag ON — un-ticking the box in the card changed nothing, and the
4936
+ * adapter kept advertising `['text','image']`. The checkbox existed and did
4937
+ * not work, which is worse than not offering it.
4938
+ *
4939
+ * So when the user HAS saved an image selection, that set is the answer, in
4940
+ * both directions. When they have not (undefined — a fresh install, or a
4941
+ * config written before this fix), the upstream capability stands: silently
4942
+ * disabling image input for everyone who never touched the switch would be a
4943
+ * regression, and the absent case is exactly "no opinion".
4750
4944
  */
4751
4945
  visible() {
4752
4946
  const enabled = this.selection.enabledModelIds;
@@ -4756,7 +4950,7 @@ var WorkBuddyCatalog = class {
4756
4950
  const budgets = this.selection.contextBudgets;
4757
4951
  return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
4758
4952
  const next = { ...model };
4759
- if (imageSet !== void 0) next.supportsImages = next.supportsImages || imageSet.has(model.id);
4953
+ if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
4760
4954
  const budget = budgets?.[model.id];
4761
4955
  if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
4762
4956
  return next;
@@ -5044,6 +5238,18 @@ const POOL_RELAY_PATH = "/plugins/dsh-xdpool/relay";
5044
5238
  * instead of a second one that could quietly drift.
5045
5239
  */
5046
5240
  const POOL_USAGE_PATH = "/plugins/dsh-xdpool/usage";
5241
+ /** The five strategies, as a value, so a request body can be validated against it. */
5242
+ const POOL_DISTRIBUTIONS = Object.freeze([
5243
+ "priority",
5244
+ "round-robin",
5245
+ "balanced",
5246
+ "sticky",
5247
+ "expiry"
5248
+ ]);
5249
+ /** True when `value` names one of the five documented strategies. */
5250
+ function isDistributionStrategy(value) {
5251
+ return POOL_DISTRIBUTIONS.includes(value);
5252
+ }
5047
5253
  /**
5048
5254
  * The schedule every automation job falls back to.
5049
5255
  *
@@ -10300,6 +10506,24 @@ const PROVIDERS = Object.freeze([
10300
10506
  "website": "https://openai.com/",
10301
10507
  "local": false,
10302
10508
  "source": "fcc"
10509
+ },
10510
+ {
10511
+ "id": "custom_openai",
10512
+ "name": "Custom OpenAI-compatible",
10513
+ "category": "apikey",
10514
+ "auth": {
10515
+ "kind": "apiKey",
10516
+ "env": "CUSTOM_OPENAI_API_KEY",
10517
+ "url": null
10518
+ },
10519
+ "baseUrl": "",
10520
+ "format": "openai-chat",
10521
+ "models": [],
10522
+ "notes": "Any OpenAI-compatible endpoint — vLLM, LiteLLM, one-api, LM Studio, a self-hosted gateway. Paste the base URL (ending in /v1) and the key on this card, then press Fetch models.",
10523
+ "website": null,
10524
+ "local": false,
10525
+ "baseUrlOverrideEnv": "CUSTOM_OPENAI_BASE_URL",
10526
+ "custom": true
10303
10527
  }
10304
10528
  ].map((entry) => Object.freeze({
10305
10529
  ...entry,
@@ -10412,6 +10636,71 @@ const COMBO_PREFIX = "combo:";
10412
10636
  const COMBO_CHANNEL = "combo";
10413
10637
  /** Chain strategies. Anything else on disk falls back to `fallback`. */
10414
10638
  const COMBO_STRATEGIES = ["fallback", "round-robin"];
10639
+ /** The owner id the pool lane uses in a platform group. */
10640
+ const POOL_OWNER = "workbuddy";
10641
+ /**
10642
+ * Flatten platform groups into the member ids the router walks.
10643
+ *
10644
+ * Platform order first, then model order inside each platform — the documented
10645
+ * semantics ("same platform: try the next model; platform exhausted: the next
10646
+ * platform"). A pool model is its bare id; anything else carries its owner as the
10647
+ * `provider/` prefix, the same composite id the aggregate catalog and the picker
10648
+ * use.
10649
+ *
10650
+ * Duplicates are dropped: the same pair twice would spend two attempts on one
10651
+ * endpoint and report a fallback that never happened.
10652
+ */
10653
+ function flattenPlatforms(platforms) {
10654
+ const out = [];
10655
+ const seen = /* @__PURE__ */ new Set();
10656
+ for (const platform of platforms) {
10657
+ const owner = text$6(platform.owner);
10658
+ if (owner === "") continue;
10659
+ for (const raw of platform.models) {
10660
+ const model = text$6(raw);
10661
+ if (model === "" || isComboId(model)) continue;
10662
+ const id = owner === "workbuddy" ? model : `${owner}/${model}`;
10663
+ if (seen.has(id)) continue;
10664
+ seen.add(id);
10665
+ out.push(id);
10666
+ }
10667
+ }
10668
+ return out;
10669
+ }
10670
+ /**
10671
+ * Group a flat member list back into platforms.
10672
+ *
10673
+ * For a chain saved flat, so the two-level editor can still open it: each id's
10674
+ * owner is read from its shape (`workbuddy` for a bare id, the prefix otherwise)
10675
+ * and groups come out in first-seen order — which is the order the members were
10676
+ * in, so re-flattening reproduces exactly what was stored.
10677
+ */
10678
+ function splitPlatforms(models) {
10679
+ const groups = /* @__PURE__ */ new Map();
10680
+ const order = [];
10681
+ for (const raw of models) {
10682
+ const id = text$6(raw);
10683
+ if (id === "") continue;
10684
+ const parsed = parseProviderModelId(id);
10685
+ const owner = parsed === null ? POOL_OWNER : parsed.providerId;
10686
+ const model = parsed === null ? id : parsed.modelId;
10687
+ let bucket = groups.get(owner);
10688
+ if (bucket === void 0) {
10689
+ bucket = [];
10690
+ groups.set(owner, bucket);
10691
+ order.push(owner);
10692
+ }
10693
+ if (!bucket.includes(model)) bucket.push(model);
10694
+ }
10695
+ return order.map((owner) => ({
10696
+ owner,
10697
+ models: groups.get(owner) ?? []
10698
+ }));
10699
+ }
10700
+ /** Windows assumed when nothing on the chain declares one. */
10701
+ const COMBO_DEFAULT_CONTEXT_WINDOW = 131072;
10702
+ /** Output ceiling assumed when nothing on the chain declares one. */
10703
+ const COMBO_DEFAULT_MAX_OUTPUT = 32768;
10415
10704
  /** A trimmed non-empty string, or ''. */
10416
10705
  function text$6(value) {
10417
10706
  return typeof value === "string" ? value.trim() : "";
@@ -10481,7 +10770,8 @@ function normalizeCombos(raw) {
10481
10770
  const name = text$6(value.name) || text$6(value.id);
10482
10771
  if (name === "") continue;
10483
10772
  const id = text$6(value.id);
10484
- const models = stringList$2(value.models).filter((model) => model !== id && !isComboId(model));
10773
+ const platforms = Array.isArray(value.platforms) ? normalizePlatforms(value.platforms) : void 0;
10774
+ const models = platforms === void 0 ? stringList$2(value.models).filter((model) => model !== id && !isComboId(model)) : flattenPlatforms(platforms);
10485
10775
  if (models.length === 0) continue;
10486
10776
  const chainId = isComboId(id) ? id : comboId(name);
10487
10777
  if (chainId === "" || seen.has(chainId)) continue;
@@ -10492,6 +10782,7 @@ function normalizeCombos(raw) {
10492
10782
  id: chainId,
10493
10783
  name,
10494
10784
  models,
10785
+ ...platforms === void 0 ? {} : { platforms },
10495
10786
  strategy,
10496
10787
  stickyLimit: limit > 0 ? limit : 1,
10497
10788
  enabled: value.enabled !== false
@@ -10500,6 +10791,30 @@ function normalizeCombos(raw) {
10500
10791
  return out;
10501
10792
  }
10502
10793
  /**
10794
+ * Normalise the `platforms` array from an untrusted document.
10795
+ *
10796
+ * Drops a group with no owner or no models rather than keeping it: an empty group
10797
+ * renders as a platform card the user cannot act on, and it contributes no members,
10798
+ * so keeping it would make the editor disagree with the router about what the chain
10799
+ * contains.
10800
+ */
10801
+ function normalizePlatforms(raw) {
10802
+ const out = [];
10803
+ for (const entry of raw) {
10804
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
10805
+ const value = entry;
10806
+ const owner = text$6(value.owner);
10807
+ if (owner === "") continue;
10808
+ const models = stringList$2(value.models).filter((model) => !isComboId(model));
10809
+ if (models.length === 0) continue;
10810
+ out.push({
10811
+ owner,
10812
+ models
10813
+ });
10814
+ }
10815
+ return out;
10816
+ }
10817
+ /**
10503
10818
  * Classify one member id against both catalogs.
10504
10819
  *
10505
10820
  * `combo:` ids are rejected outright: a chain pointing at another chain would
@@ -10522,6 +10837,31 @@ function resolveComboMember(modelId, sources) {
10522
10837
  supportsImages: aggregateRow.supportsImages
10523
10838
  };
10524
10839
  }
10840
+ /**
10841
+ * A 扫码登录 channel row, checked BEFORE the pool.
10842
+ *
10843
+ * Channel ids are `<channel>:<model>`, and the channel set is closed (it comes
10844
+ * from the pack's own registered list), so a match here is unambiguous. Order
10845
+ * still matters: a pool model id is bare and can never contain `:`, so trying the
10846
+ * channel first cannot shadow one — and doing it first keeps a channel named after
10847
+ * a pool id from being swallowed.
10848
+ *
10849
+ * The window and image flag are not known for a channel row (the host's listing
10850
+ * publishes neither), so the chain's intersection falls back to the documented
10851
+ * defaults. That is the honest answer: an unknown window must not be treated as a
10852
+ * large one, or the host would size a compaction against a limit nobody promised.
10853
+ */
10854
+ const hostRow = sources.host?.find((row) => row.id === id);
10855
+ if (hostRow !== void 0) return {
10856
+ modelId: id,
10857
+ aggregate: false,
10858
+ host: true,
10859
+ channel: hostRow.channel,
10860
+ name: hostRow.name,
10861
+ contextWindow: COMBO_DEFAULT_CONTEXT_WINDOW,
10862
+ maxOutputTokens: COMBO_DEFAULT_MAX_OUTPUT,
10863
+ supportsImages: false
10864
+ };
10525
10865
  const poolRow = sources.pool.find((row) => row.id === id);
10526
10866
  if (poolRow === void 0) return void 0;
10527
10867
  return {
@@ -10534,6 +10874,54 @@ function resolveComboMember(modelId, sources) {
10534
10874
  };
10535
10875
  }
10536
10876
  /**
10877
+ * The same model served by more than one lane, in the order to try them.
10878
+ *
10879
+ * This is the "same model, different route" tier: when a lane rate-limits, the
10880
+ * answer is usually to reach the SAME model through another provider, not to
10881
+ * fall back to a different model. Switching models changes context window, tool
10882
+ * calling, structured-output shape and writing style — a cross-model fallback is
10883
+ * a last resort, not a first one.
10884
+ *
10885
+ * The registry carries 86 model ids under two or more providers (kimi-k2.5 sits
10886
+ * under eight), so the tier is real and not theoretical. What was missing is
10887
+ * that a user had to spell it out: three separate chain members naming the same
10888
+ * model three times, in the right order, and re-edit the chain whenever a
10889
+ * provider was added.
10890
+ *
10891
+ * Derivation, not configuration: for one model id, every aggregate row sharing
10892
+ * `modelName` (or the bare id when the registry names no shared display name) is
10893
+ * a route to it. Pool rows are excluded — `buddy:`/`workbuddy:` are the pool's
10894
+ * own lanes and naming them here would silently spend the user's WorkBuddy
10895
+ * credits as a "fallback".
10896
+ *
10897
+ * The requested provider, when given, comes FIRST: the user chose that lane, so
10898
+ * it stays the primary and the others are the alternates.
10899
+ */
10900
+ function siblingRoutes(modelId, sources, preferredProvider) {
10901
+ const id = text$6(modelId);
10902
+ if (id === "") return [];
10903
+ const target = sources.aggregate.find((row) => row.id === id);
10904
+ if (target === void 0) return [id];
10905
+ const name = text$6(target.name);
10906
+ const bare = bareModelIdOf(target);
10907
+ const out = [id];
10908
+ for (const row of sources.aggregate) {
10909
+ if (row.id === id) continue;
10910
+ if (row.providerId === target.providerId) continue;
10911
+ const sameName = name !== "" && text$6(row.name) === name;
10912
+ const sameBare = bare !== "" && bareModelIdOf(row) === bare;
10913
+ if (!sameName && !sameBare) continue;
10914
+ if (!out.includes(row.id)) out.push(row.id);
10915
+ }
10916
+ if (preferredProvider === void 0 || out.length <= 1) return out;
10917
+ return [...out.filter((candidate) => candidate.startsWith(`${preferredProvider}/`)), ...out.filter((candidate) => !candidate.startsWith(`${preferredProvider}/`))];
10918
+ }
10919
+ /** The model id without its provider prefix, for cross-vendor comparison. */
10920
+ function bareModelIdOf(row) {
10921
+ const parsed = parseProviderModelId(row.id);
10922
+ return parsed === null ? text$6(row.modelId) : parsed.modelId;
10923
+ }
10924
+ /**
10537
10925
  * Every combo row that can currently answer, given the catalogs.
10538
10926
  *
10539
10927
  * Disabled chains are skipped, as are chains whose members have all gone away:
@@ -10657,6 +11045,13 @@ function normalizeSetting(raw) {
10657
11045
  if (typeof value.enabled === "boolean") setting.enabled = value.enabled;
10658
11046
  const key = text$5(value.apiKey);
10659
11047
  if (key !== "") setting.apiKey = key;
11048
+ if (Array.isArray(value.apiKeys)) {
11049
+ const keys = stringList$1(value.apiKeys);
11050
+ const [only] = keys;
11051
+ if (keys.length > 1) setting.apiKeys = keys;
11052
+ else if (only !== void 0 && setting.apiKey === void 0) setting.apiKey = only;
11053
+ }
11054
+ if (typeof value.distribution === "string" && isDistributionStrategy(value.distribution.trim())) setting.distribution = value.distribution.trim();
10660
11055
  const baseUrl = text$5(value.baseUrl);
10661
11056
  if (baseUrl !== "") setting.baseUrl = baseUrl.replace(/\/+$/, "");
10662
11057
  if (Array.isArray(value.models)) {
@@ -10777,12 +11172,12 @@ function normalizeRelayModels(raw) {
10777
11172
  async function writeAggregateSettings(settings) {
10778
11173
  const path = aggregateSettingsPath();
10779
11174
  if (path === void 0) throw new Error("no home directory to store the aggregate settings in");
10780
- await mkdir(dirname(path), { recursive: true });
11175
+ await ensureSecretDir(dirname(path));
10781
11176
  const file = {
10782
11177
  version: STORE_VERSION,
10783
11178
  ...settings
10784
11179
  };
10785
- await writeFile(`${path}.tmp`, `${JSON.stringify(file, null, 2)}\n`, "utf8");
11180
+ await writeSecretFile(`${path}.tmp`, `${JSON.stringify(file, null, 2)}\n`);
10786
11181
  await rename(`${path}.tmp`, path);
10787
11182
  }
10788
11183
  /**
@@ -10804,16 +11199,42 @@ async function updateProviderSetting(providerId, patch) {
10804
11199
  if (baseUrl === "") delete after.baseUrl;
10805
11200
  else after.baseUrl = baseUrl;
10806
11201
  }
10807
- if (patch.apiKey !== void 0) {
10808
- const key = text$5(patch.apiKey);
10809
- if (key === "") delete after.apiKey;
10810
- else after.apiKey = key;
11202
+ if (patch.appendApiKey !== void 0) {
11203
+ const added = text$5(patch.appendApiKey);
11204
+ if (added !== "") {
11205
+ const existing = [.../* @__PURE__ */ new Set([...providerApiKeys(after)])];
11206
+ if (!existing.includes(added)) existing.push(added);
11207
+ const first = existing[0];
11208
+ if (first !== void 0) {
11209
+ after.apiKey = first;
11210
+ if (existing.length > 1) after.apiKeys = existing;
11211
+ else delete after.apiKeys;
11212
+ }
11213
+ }
11214
+ }
11215
+ if (patch.apiKeys !== void 0 || patch.apiKey !== void 0) {
11216
+ const incoming = patch.apiKeys !== void 0 ? patch.apiKeys : [patch.apiKey ?? ""];
11217
+ const keys = [...new Set(incoming.map((entry) => text$5(entry)).filter((key) => key !== ""))];
11218
+ const first = keys[0];
11219
+ if (first === void 0) {
11220
+ delete after.apiKey;
11221
+ delete after.apiKeys;
11222
+ } else {
11223
+ after.apiKey = first;
11224
+ if (keys.length > 1) after.apiKeys = keys;
11225
+ else delete after.apiKeys;
11226
+ }
10811
11227
  }
10812
11228
  if (patch.models !== void 0) {
10813
11229
  const models = stringList$1(patch.models);
10814
11230
  if (models.length === 0) delete after.models;
10815
11231
  else after.models = models;
10816
11232
  }
11233
+ if (patch.distribution !== void 0) {
11234
+ const strategy = text$5(patch.distribution);
11235
+ if (strategy === "") delete after.distribution;
11236
+ else after.distribution = strategy;
11237
+ }
10817
11238
  if (patch.discovered !== void 0) {
10818
11239
  const discovered = stringList$1(patch.discovered);
10819
11240
  if (discovered.length === 0) delete after.discovered;
@@ -10912,6 +11333,62 @@ function providerSettingOf(store, providerId) {
10912
11333
  return store.providers?.[providerId] ?? {};
10913
11334
  }
10914
11335
  /**
11336
+ * Every usable key a provider holds, preferred first.
11337
+ *
11338
+ * One reader for two spellings. A config written before multi-key support has
11339
+ * only `apiKey`; one written by the multi-key card has `apiKeys` with a mirrored
11340
+ * `apiKey`. Both must answer the same list, deduplicated, with blanks dropped — a
11341
+ * blank entry is a request guaranteed to 401 and, worse, would make the card
11342
+ * believe the provider is configured.
11343
+ *
11344
+ * Order is meaningful: entry 0 is the preferred key, the rest are alternates.
11345
+ */
11346
+ function providerApiKeys(setting) {
11347
+ const list = Array.isArray(setting.apiKeys) ? setting.apiKeys : [];
11348
+ const out = [];
11349
+ const seen = /* @__PURE__ */ new Set();
11350
+ for (const raw of [...list, setting.apiKey]) {
11351
+ const key = text$5(raw);
11352
+ if (key === "" || seen.has(key)) continue;
11353
+ seen.add(key);
11354
+ out.push(key);
11355
+ }
11356
+ return out;
11357
+ }
11358
+ /**
11359
+ * The order to TRY this provider's keys in, per the chosen strategy.
11360
+ *
11361
+ * `priority` (the default) is the stored order, which is also what the retry loop
11362
+ * walks: the preferred key first, then the alternates. The other four strategies
11363
+ * reorder the FIRST attempt only — the alternates behind it still follow in
11364
+ * stored order, because the point of a fallback is to reach a working key quickly,
11365
+ * not to shuffle the whole list on every attempt.
11366
+ *
11367
+ * Deliberately a pure function of `(keys, strategy, picker)`: the pool lane's
11368
+ * strategies depend on live account state, which keys do not have. `picker`
11369
+ * returns a float in [0,1) so a test can drive the random branches.
11370
+ */
11371
+ function orderApiKeys(keys, strategy, picker = Math.random) {
11372
+ if (keys.length <= 1) return keys;
11373
+ const [first, ...rest] = keys;
11374
+ if (first === void 0) return keys;
11375
+ switch (strategy) {
11376
+ case "round-robin": {
11377
+ const at = Math.floor(picker() * keys.length);
11378
+ const index = at < 0 || at >= keys.length ? 0 : at;
11379
+ return [...keys.slice(index), ...keys.slice(0, index)];
11380
+ }
11381
+ case "balanced": {
11382
+ const at = Math.floor(picker() * keys.length);
11383
+ const index = at < 0 || at >= keys.length ? 0 : at;
11384
+ return [...keys.slice(index), ...keys.slice(0, index)];
11385
+ }
11386
+ case "expiry": return keys;
11387
+ case "sticky": return keys;
11388
+ default: return keys;
11389
+ }
11390
+ }
11391
+ /**
10915
11392
  * Whether a provider could serve a model right now.
10916
11393
  *
10917
11394
  * Keyless and local providers are usable with nothing configured; `oauth` and
@@ -10921,7 +11398,7 @@ function providerSettingOf(store, providerId) {
10921
11398
  */
10922
11399
  function providerAuthUsable(provider, setting) {
10923
11400
  if (provider.auth.kind === "oauth" || provider.auth.kind === "adc") return false;
10924
- if (provider.auth.kind === "apiKey") return text$5(setting.apiKey) !== "";
11401
+ if (provider.auth.kind === "apiKey") return providerApiKeys(setting).length > 0;
10925
11402
  return true;
10926
11403
  }
10927
11404
  /**
@@ -10966,298 +11443,676 @@ function configuredProviderIds(store) {
10966
11443
  return out;
10967
11444
  }
10968
11445
  //#endregion
10969
- //#region src/aggregate.ts
11446
+ //#region src/stats.ts
10970
11447
  /**
10971
- * Aggregate lane: the 76 declared upstream providers, resolved per request and
10972
- * forwarded to with the user's own key.
10973
- *
10974
- * WHY A SECOND LANE BESIDE THE POOL
10975
- *
10976
- * The pool serves exactly one family of gateways — WorkBuddy, CN and
10977
- * international — from accounts the user already signed in on the desktop app.
10978
- * This module serves the orthogonal case: a provider the user holds a key for,
10979
- * reached at that provider's own endpoint, with no account rotation because
10980
- * there is no account to rotate. Same card, same model picker, different wire.
10981
- *
10982
- * WHAT IT DELIBERATELY DOES NOT DO
10983
- *
10984
- * It does not invent endpoints. Every URL here comes from the registry record or
10985
- * from a base-URL override the user typed; a provider whose endpoint cannot be
10986
- * composed is reported as such rather than being probed at a guessed URL, because
10987
- * "no listing" and "provider is down" must not read the same to the user.
10988
- *
10989
- * It also does not decide whether a provider is USABLE — that is the settings
10990
- * layer's call (`providerAuthUsable`). This module resolves what a request
10991
- * needs, and refuses with a message naming what is missing.
10992
- *
10993
- * KEYS
10994
- *
10995
- * Every string that can reach a message — a failure, a body snippet, a probe's
10996
- * answer — passes through {@link redactAggregateSecret} first. Providers echo
10997
- * rejected credentials back (a 401 body routinely repeats the key), and this
10998
- * text is folded into messages the host writes to its durable session log.
10999
- *
11000
- * @module dsh-xdpool/aggregate
11448
+ * Retained calls. Bounded on purpose: a busy week is thousands of calls, and a
11449
+ * dashboard that grows without limit turns into a memory leak with a chart.
11001
11450
  */
11002
- const WIRE_BY_FORMAT = Object.freeze({
11003
- "openai-chat": "chat",
11004
- "openai-responses": "responses",
11005
- "anthropic-messages": "messages"
11006
- });
11451
+ const USAGE_MAX_SAMPLES = 2e3;
11007
11452
  /**
11008
- * Wire suffixes, needed only for the one record the registry gives no base URL
11009
- * to (Azure names its endpoint per deployment, so `chatEndpoint` is null there
11010
- * and there is no registry endpoint to read the suffix off).
11453
+ * Minimum total decoding time before a throughput figure is published.
11454
+ *
11455
+ * A one-token answer that arrives in 20ms would otherwise report a tps two
11456
+ * orders of magnitude above anything a real turn reaches, and the panel would
11457
+ * rank models by that artefact instead of by speed.
11011
11458
  */
11012
- const CHAT_SUFFIX = Object.freeze({
11013
- "openai-chat": "/chat/completions",
11014
- "openai-responses": "/responses",
11015
- "anthropic-messages": "/messages"
11016
- });
11459
+ const MIN_DECODE_MS = 120;
11017
11460
  /**
11018
- * Capacities for a row the registry describes only by id.
11461
+ * A reported token count, or null when the key is absent / not a finite
11462
+ * number.
11019
11463
  *
11020
- * Deliberately conservative, and the reason is visible in the number: most
11021
- * registry rows publish no context window at all. Publishing a plausible one
11022
- * would put a fabricated field in front of the user — and unlike a display
11023
- * label, a wrong window is a wrong capability, so DSH would hand the provider a
11024
- * prompt it cannot accept.
11464
+ * Zero is ACCEPTED: an empty turn really can cost nothing, and reporting that
11465
+ * as "the gateway said nothing" would make the panel lie in the other
11466
+ * direction.
11025
11467
  */
11026
- const DEFAULT_CONTEXT_WINDOW = 131072;
11027
- const DEFAULT_MAX_OUTPUT = 32768;
11028
- /** Listing round budget. Generous for a small JSON document, short for a UI. */
11029
- const LISTING_TIMEOUT_MS = 15e3;
11030
- /** One turn's budget, matching the pool shim's idle ceiling. */
11031
- const TURN_TIMEOUT_MS = 3e5;
11032
- /** Sent when the registry does not declare one (kimchi, and most of the roster). */
11033
- const USER_AGENT = "dsh-xdpool/2.1";
11034
- /** How much of an upstream body is quoted back in a failure message. */
11035
- const BODY_SNIPPET_CHARS = 400;
11036
- /** A trimmed non-empty string, or ''. */
11037
- function text$4(value) {
11038
- return typeof value === "string" && value.trim() !== "" ? value.trim() : "";
11468
+ function count(value) {
11469
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
11039
11470
  }
11040
- /** A positive finite number, or undefined. */
11041
- function number(value) {
11042
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : void 0;
11471
+ function zero(value) {
11472
+ return count(value) ?? 0;
11043
11473
  }
11044
- function isRecord$3(value) {
11045
- return value !== null && typeof value === "object" && !Array.isArray(value);
11474
+ function detailOf(value, key) {
11475
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return 0;
11476
+ return zero(value[key]);
11046
11477
  }
11047
11478
  /**
11048
- * One aggregate-lane failure, carrying the class the caller maps onto HTTP.
11479
+ * Read a gateway `usage` object, accepting both spellings OpenAI-compatible
11480
+ * gateways use (`prompt_tokens` / `input_tokens`).
11049
11481
  *
11050
- * `kind` reuses the pool's vocabulary (`UpstreamErrorKind` in `upstream.ts`) so
11051
- * the shim's existing error→status table serves both lanes: a third-party 429
11052
- * is a `soft_rate`, a 401 is a `session_dead`, and neither needs a second table
11053
- * that could drift from the first.
11482
+ * Returns undefined when the document is not a usage report at all, so callers
11483
+ * can tell "the gateway sent zeroes" from "there was nothing here".
11054
11484
  */
11055
- var AggregateError = class extends Error {
11056
- kind;
11057
- status;
11058
- constructor(kind, message, status = 0) {
11059
- super(message);
11060
- this.name = "AggregateError";
11061
- this.kind = kind;
11062
- this.status = status;
11063
- }
11064
- };
11065
- /** Remove a credential from anything that may reach a message. */
11066
- function redactAggregateSecret(value, apiKey) {
11067
- let source = typeof value === "string" ? value : String(value ?? "");
11068
- const key = text$4(apiKey);
11069
- if (key === "") return source;
11070
- const escaped = JSON.stringify(key).slice(1, -1);
11071
- for (const needle of escaped === key ? [key] : [key, escaped]) if (source.includes(needle)) source = source.split(needle).join("[redacted]");
11072
- return source;
11073
- }
11074
- /** A base URL a request can actually be sent to. */
11075
- function hasPlaceholder(baseUrl) {
11076
- return /\{[^}]*\}/.test(baseUrl);
11077
- }
11078
- /** The user's `baseUrl` override when present, else the registry's own. */
11079
- function effectiveBaseUrl(provider, setting) {
11080
- const override = text$4(setting.baseUrl);
11081
- return (override !== "" ? override : text$4(provider.baseUrl)).replace(/\/+$/, "");
11082
- }
11083
- /**
11084
- * The provider's listing URL, moved by a `baseUrl` override when — and only
11085
- * when — the declared listing actually lives under the registry base.
11086
- *
11087
- * Three providers (cohere, deepinfra, ollama_cloud) publish their listing on
11088
- * another path. Rewriting a URL that never pointed at the override would invent
11089
- * one, and a guessed listing is worse than none.
11090
- */
11091
- function effectiveModelsUrl(provider, setting) {
11092
- const declared = modelsEndpoint(provider);
11093
- if (declared === null) return null;
11094
- const override = text$4(setting.baseUrl).replace(/\/+$/, "");
11095
- const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
11096
- if (override === "" || registryBase === "" || !declared.startsWith(registryBase)) return declared;
11097
- return `${override}${declared.slice(registryBase.length)}`;
11098
- }
11099
- /**
11100
- * The full URL a turn is posted to; null when the provider has no dialable
11101
- * endpoint (Azure without a user base URL, or a base still carrying the
11102
- * registry's `{account_id}`-style placeholder).
11103
- */
11104
- function effectiveChatUrl(provider, setting) {
11105
- const override = text$4(setting.baseUrl).replace(/\/+$/, "");
11106
- const declared = chatEndpoint(provider);
11107
- if (declared !== null) {
11108
- const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
11109
- const url = override === "" || registryBase === "" ? declared : `${override}${declared.slice(registryBase.length)}`;
11110
- return hasPlaceholder(url) ? null : url;
11111
- }
11112
- const suffix = CHAT_SUFFIX[provider.format];
11113
- if (override === "" || suffix === void 0 || hasPlaceholder(override)) return null;
11114
- return `${override}${suffix}`;
11115
- }
11116
- /** The per-provider settings object, or `{}` when the store names none. */
11117
- function settingFor(store, providerId) {
11118
- return store.providers?.[providerId] ?? {};
11119
- }
11120
- /**
11121
- * Resolve one provider's effective settings.
11122
- *
11123
- * Throws {@link AggregateError} with a `client` kind for every state the user
11124
- * fixes in the settings page — an unknown id, a missing key, a placeholder base
11125
- * URL. A `client` kind is deliberate: none of these is a transport fault, so
11126
- * none of them should be retried against a different provider.
11127
- *
11128
- * `options.requireEnabled` exists for the DISCOVERY calls. Fetching a provider's
11129
- * model list publishes nothing: it asks "does this endpoint answer, and with
11130
- * what", and writing the answer back is a separate step the user also takes.
11131
- * Gating it on the enable switch made a read-only button report
11132
- * `"OpenCode Free" is not enabled in the aggregate settings` — a failure to
11133
- * LOOK, dressed up as a failure to connect, on rows that carry no key field and
11134
- * so offered no way to answer it. Discovery therefore does not require the
11135
- * switch; publishing still does, in {@link buildAggregateCatalog} and
11136
- * {@link resolveAggregateProvider} as called by the serving paths.
11137
- */
11138
- function resolveAggregateProvider(store, providerId, options = {}) {
11139
- const id = text$4(providerId);
11140
- const provider = id === "" ? null : getProvider(id);
11141
- if (provider === null) throw new AggregateError("client", `the aggregate lane has no provider "${id === "" ? String(providerId) : id}"`);
11142
- const setting = settingFor(store, provider.id);
11143
- if (setting.enabled === false) throw new AggregateError("client", `"${provider.name}" is switched off in the aggregate settings`);
11144
- if (!providerAuthUsable(provider, setting)) {
11145
- if (provider.auth.kind === "oauth" || provider.auth.kind === "adc") throw new AggregateError("session_dead", `"${provider.name}" signs in with ${provider.auth.kind}, not with an API key, so the aggregate lane does not serve it`);
11146
- const where = text$4(provider.auth.url) === "" ? "" : ` (the provider issues keys at ${provider.auth.url})`;
11147
- throw new AggregateError("client", `"${provider.name}" needs an API key: paste one in the aggregate settings${where}`);
11148
- }
11149
- if (options.requireEnabled !== false && !providerEnabled(provider, setting)) throw new AggregateError("client", `"${provider.name}" is not enabled in the aggregate settings`);
11150
- const baseUrl = effectiveBaseUrl(provider, setting);
11151
- if (baseUrl === "") throw new AggregateError("client", `"${provider.name}" has no base URL of its own — set one in the aggregate settings`);
11152
- if (hasPlaceholder(baseUrl)) throw new AggregateError("client", `"${provider.name}"'s base URL still carries a registry placeholder — set the real URL in the aggregate settings`);
11153
- const chatUrl = effectiveChatUrl(provider, setting);
11154
- if (chatUrl === null) throw new AggregateError("client", `"${provider.name}" has no usable ${provider.format} endpoint`);
11155
- const apiKey = text$4(setting.apiKey);
11156
- const headers = defaultHeaders(provider, apiKey);
11157
- if (headers["user-agent"] === void 0) headers["user-agent"] = USER_AGENT;
11158
- return {
11159
- providerId: provider.id,
11160
- providerName: provider.name,
11161
- apiKey,
11162
- baseUrl,
11163
- format: provider.format,
11164
- wire: WIRE_BY_FORMAT[provider.format],
11165
- chatUrl,
11166
- modelsUrl: effectiveModelsUrl(provider, setting),
11167
- headers
11168
- };
11169
- }
11170
- /**
11171
- * The ids a provider should be served with, and where that list came from.
11172
- *
11173
- * `discovered` is the roster a fetch returned, `models` is the subset the user
11174
- * kept, and the choice is applied LAST so it wins over both — otherwise pressing
11175
- * "Fetch models" would silently undo the narrowing the card just saved.
11176
- */
11177
- function modelIdsFor(provider, setting) {
11178
- const universe = modelChoicesFor(provider, setting);
11179
- const source = rosterIsDiscovered(provider, setting) ? "discovered" : "static";
11180
- const override = stringList(setting.models);
11181
- if (override.length === 0) return {
11182
- ids: universe,
11183
- source
11184
- };
11185
- const available = new Set(universe);
11485
+ function parseUsage(value) {
11486
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
11487
+ const obj = value;
11488
+ const input = count(obj["prompt_tokens"]) ?? count(obj["input_tokens"]);
11489
+ const output = count(obj["completion_tokens"]) ?? count(obj["output_tokens"]);
11490
+ if (input === null && output === null) return void 0;
11186
11491
  return {
11187
- ids: override.filter((id) => available.has(id)),
11188
- source
11492
+ inputTokens: input ?? 0,
11493
+ outputTokens: output ?? 0,
11494
+ reasoningTokens: detailOf(obj["completion_tokens_details"], "reasoning_tokens"),
11495
+ cachedTokens: detailOf(obj["prompt_tokens_details"], "cached_tokens")
11189
11496
  };
11190
11497
  }
11191
11498
  /**
11192
- * Whether the roster came from a live fetch rather than the registry.
11499
+ * True for the trailer frame a gateway sends to report usage.
11193
11500
  *
11194
- * A pre-split install has `models` and no `discovered`, because until the
11195
- * chooser existed the only writer of `models` WAS the fetch button. Those ids
11196
- * are a roster, not a choice, and treating them as one would shrink the vendor
11197
- * to whatever handful the registry happens to declare.
11501
+ * It is recognised by an EMPTY `choices` array beside a `usage` field. A frame
11502
+ * that also carries content is a normal chunk that happens to mention usage,
11503
+ * and dropping it would delete the user's answer - which is why the strip step
11504
+ * downstream is allowed to remove exactly these and nothing else.
11198
11505
  */
11199
- function rosterIsDiscovered(provider, setting) {
11200
- if ((setting.discovered?.length ?? 0) > 0) return true;
11201
- return stringList(setting.models).length > provider.models.length;
11202
- }
11203
- /** Every id a provider could offer: its roster, for the card's chooser. */
11204
- function modelChoicesFor(provider, setting) {
11205
- const discovered = stringList(setting.discovered);
11206
- if (discovered.length === 0 && rosterIsDiscovered(provider, setting)) return stringList(setting.models);
11207
- return discovered.length > 0 ? discovered : provider.models.map((model) => model.id);
11506
+ function isUsageOnlyFrame(parsed) {
11507
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
11508
+ const obj = parsed;
11509
+ if (obj["usage"] === void 0 || obj["usage"] === null) return false;
11510
+ const choices = obj["choices"];
11511
+ return Array.isArray(choices) && choices.length === 0;
11208
11512
  }
11209
- /** A de-duplicated list of non-empty trimmed strings from an untrusted array. */
11210
- function stringList(value) {
11211
- if (!Array.isArray(value)) return [];
11212
- const seen = /* @__PURE__ */ new Set();
11213
- const ids = [];
11214
- for (const raw of value) {
11215
- const id = text$4(raw);
11216
- if (id === "" || seen.has(id)) continue;
11217
- seen.add(id);
11218
- ids.push(id);
11219
- }
11220
- return ids;
11513
+ /** Local calendar day of an instant, as `YYYY-MM-DD`. */
11514
+ function usageDayKey(at) {
11515
+ const d = new Date(at);
11516
+ const month = String(d.getMonth() + 1).padStart(2, "0");
11517
+ const day = String(d.getDate()).padStart(2, "0");
11518
+ return `${d.getFullYear()}-${month}-${day}`;
11221
11519
  }
11222
11520
  /**
11223
- * One catalog row.
11521
+ * Timing and usage captured while one stream is forwarded.
11224
11522
  *
11225
- * Capacities come from the registry's model row where it has them, and fall back
11226
- * to this module's own defaults where it does not (most rows carry no context
11227
- * window, and none carries an output ceiling — the registry records ids and
11228
- * published capacities, not a serving contract).
11523
+ * Kept free of Node stream types so it can be unit tested with plain numbers;
11524
+ * the shim owns the Transform that feeds it chunks.
11229
11525
  */
11230
- function rowOf(provider, modelId, source) {
11231
- const declared = provider.models.find((model) => model.id === modelId);
11232
- const contextWindow = number(declared?.contextWindow) ?? DEFAULT_CONTEXT_WINDOW;
11233
- const label = text$4(declared?.name) !== "" ? text$4(declared?.name) : modelId;
11234
- return {
11235
- id: providerModelId(provider.id, modelId),
11236
- name: `${provider.name} ${label}`,
11237
- providerId: provider.id,
11238
- modelId,
11239
- contextWindow,
11240
- maxOutputTokens: Math.min(DEFAULT_MAX_OUTPUT, contextWindow),
11241
- supportsImages: declared?.vision === true,
11242
- wire: WIRE_BY_FORMAT[provider.format],
11243
- source
11244
- };
11245
- }
11526
+ var StreamTracker = class {
11527
+ /** Milliseconds from request start to the first byte, once seen. */
11528
+ ttftMs;
11529
+ /** Milliseconds from first byte to the end of the stream, once finished. */
11530
+ decodeMs;
11531
+ /** Usage seen in any frame, if the gateway sent any. */
11532
+ usage;
11533
+ firstAt;
11534
+ startedAt;
11535
+ constructor(startedAt = Date.now()) {
11536
+ this.startedAt = startedAt;
11537
+ }
11538
+ /** Record the first byte. Repeated calls keep the earliest. */
11539
+ markFirstByte(at = Date.now()) {
11540
+ if (this.firstAt !== void 0) return;
11541
+ this.firstAt = at;
11542
+ const ttft = at - this.startedAt;
11543
+ if (ttft >= 0) this.ttftMs = ttft;
11544
+ }
11545
+ /** Record stream completion. Repeated calls keep the earliest. */
11546
+ finish(at = Date.now()) {
11547
+ if (this.decodeMs !== void 0) return;
11548
+ if (this.firstAt === void 0) return;
11549
+ const decode = at - this.firstAt;
11550
+ if (decode >= 0) this.decodeMs = decode;
11551
+ }
11552
+ /** Fold a parsed frame's usage in; later frames overwrite, as the gateway intends. */
11553
+ observe(parsed) {
11554
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
11555
+ const usage = parseUsage(parsed["usage"]);
11556
+ if (usage !== void 0) this.usage = usage;
11557
+ }
11558
+ };
11246
11559
  /**
11247
- * Every model row the lane should offer, given the stored settings.
11560
+ * A bounded window of recent calls, summarised on demand.
11248
11561
  *
11249
- * A provider is skipped when it is switched off, when its credential flow this
11250
- * lane cannot drive, or when its endpoint cannot be composed — a picker row that
11251
- * can never answer is worse than no row, because it reads as "this provider is
11252
- * broken" rather than "not set up yet".
11562
+ * In-memory by design: this is a live panel, and persisting every prompt's
11563
+ * token counts would turn a convenience into a record of what the user asked.
11564
+ * A restart therefore starts the chart again, which the panel states rather
11565
+ * than hides.
11253
11566
  */
11254
- function buildAggregateCatalog(store) {
11255
- if (store.enabled === false) return [];
11256
- const rows = [];
11257
- for (const [providerId, setting] of Object.entries(store.providers ?? {})) {
11258
- if (setting === void 0 || setting === null) continue;
11259
- const provider = getProvider(providerId);
11260
- if (provider === null) continue;
11567
+ var UsageRecorder = class {
11568
+ samples = [];
11569
+ limit;
11570
+ constructor(limit = USAGE_MAX_SAMPLES) {
11571
+ this.limit = limit > 0 ? limit : 1;
11572
+ }
11573
+ /** Retain one call, dropping the oldest once the window is full. */
11574
+ record(call) {
11575
+ this.samples.push(call);
11576
+ while (this.samples.length > this.limit) this.samples.shift();
11577
+ }
11578
+ /** Samples currently retained. */
11579
+ size() {
11580
+ return this.samples.length;
11581
+ }
11582
+ /** Forget every retained call. */
11583
+ reset() {
11584
+ this.samples = [];
11585
+ }
11586
+ /** Aggregate the retained window. */
11587
+ summary() {
11588
+ const rows = /* @__PURE__ */ new Map();
11589
+ const turns = /* @__PURE__ */ new Map();
11590
+ let calls = 0;
11591
+ let usageReported = false;
11592
+ for (const call of this.samples) {
11593
+ calls += 1;
11594
+ if (call.usage !== void 0) usageReported = true;
11595
+ const bucket = turns.get(call.turnId) ?? {
11596
+ calls: 0,
11597
+ succeeded: 0,
11598
+ failed: 0
11599
+ };
11600
+ bucket.calls += 1;
11601
+ if (call.ok) bucket.succeeded += 1;
11602
+ else bucket.failed += 1;
11603
+ turns.set(call.turnId, bucket);
11604
+ const key = `${call.channel}\u0000${call.region}\u0000${call.model}`;
11605
+ let acc = rows.get(key);
11606
+ if (acc === void 0) {
11607
+ acc = {
11608
+ row: {
11609
+ channel: call.channel,
11610
+ region: call.region,
11611
+ model: call.model,
11612
+ calls: 0,
11613
+ turns: 0,
11614
+ failed: 0,
11615
+ recovered: 0,
11616
+ inputTokens: 0,
11617
+ outputTokens: 0,
11618
+ reasoningTokens: 0,
11619
+ cachedTokens: 0,
11620
+ tps: null,
11621
+ avgTtftMs: null,
11622
+ lastUsedAt: 0
11623
+ },
11624
+ turnIds: /* @__PURE__ */ new Set(),
11625
+ decodeMs: 0,
11626
+ ttftTotal: 0,
11627
+ ttftCount: 0
11628
+ };
11629
+ rows.set(key, acc);
11630
+ }
11631
+ acc.row.calls += 1;
11632
+ acc.turnIds.add(call.turnId);
11633
+ if (!call.ok) acc.row.failed += 1;
11634
+ if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
11635
+ if (call.ttftMs !== void 0) {
11636
+ acc.ttftTotal += call.ttftMs;
11637
+ acc.ttftCount += 1;
11638
+ }
11639
+ if (call.decodeMs !== void 0) acc.decodeMs += call.decodeMs;
11640
+ if (call.usage !== void 0) {
11641
+ acc.row.inputTokens += call.usage.inputTokens;
11642
+ acc.row.outputTokens += call.usage.outputTokens;
11643
+ acc.row.reasoningTokens += call.usage.reasoningTokens;
11644
+ acc.row.cachedTokens += call.usage.cachedTokens;
11645
+ }
11646
+ }
11647
+ const failedTurnIds = /* @__PURE__ */ new Set();
11648
+ let recoveredTurns = 0;
11649
+ for (const [turnId, bucket] of turns) if (bucket.failed > 0 && bucket.succeeded === 0) failedTurnIds.add(turnId);
11650
+ else if (bucket.failed > 0) recoveredTurns += 1;
11651
+ const out = [];
11652
+ for (const acc of rows.values()) {
11653
+ let recovered = 0;
11654
+ for (const turnId of acc.turnIds) {
11655
+ const bucket = turns.get(turnId);
11656
+ if (bucket !== void 0 && bucket.failed > 0 && bucket.succeeded > 0) recovered += 1;
11657
+ }
11658
+ out.push({
11659
+ ...acc.row,
11660
+ turns: acc.turnIds.size,
11661
+ recovered,
11662
+ tps: acc.decodeMs >= MIN_DECODE_MS ? Math.round(acc.row.outputTokens / (acc.decodeMs / 1e3)) : null,
11663
+ avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftTotal / acc.ttftCount) : null
11664
+ });
11665
+ }
11666
+ out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.model.localeCompare(b.model));
11667
+ return {
11668
+ samples: this.samples.length,
11669
+ turns: turns.size,
11670
+ failedTurns: failedTurnIds.size,
11671
+ recoveredTurns,
11672
+ calls,
11673
+ usageReported,
11674
+ entries: this.samples.slice().reverse(),
11675
+ rows: out,
11676
+ accounts: this.accountRows(),
11677
+ days: this.days(failedTurnIds)
11678
+ };
11679
+ }
11680
+ /**
11681
+ * One row per account, busiest first.
11682
+ *
11683
+ * Samples that never named an account are grouped under an empty label
11684
+ * rather than dropped: `account` is optional on `UsageCall`, so a caller that
11685
+ * files a sample without one - a failure raised before any account was ever
11686
+ * picked, say - would otherwise vanish from this view, and the account table
11687
+ * would add up to less than the call total with nothing on screen to explain
11688
+ * the gap. Every shim record site names its account; this branch exists so
11689
+ * that a future one cannot quietly break the totals.
11690
+ *
11691
+ * `topModels` keeps the two most-used ids rather than every one: this column
11692
+ * answers "what does this account usually run", and a list of nine ids is not
11693
+ * an answer to that question, it is just the other table again.
11694
+ */
11695
+ accountRows() {
11696
+ const buckets = /* @__PURE__ */ new Map();
11697
+ for (const call of this.samples) {
11698
+ const label = call.account ?? "";
11699
+ const key = `${call.channel}\u0000${call.region}\u0000${label}`;
11700
+ let acc = buckets.get(key);
11701
+ if (acc === void 0) {
11702
+ acc = {
11703
+ row: {
11704
+ account: label,
11705
+ channel: call.channel,
11706
+ region: call.region,
11707
+ calls: 0,
11708
+ turns: 0,
11709
+ failed: 0,
11710
+ inputTokens: 0,
11711
+ outputTokens: 0,
11712
+ cachedTokens: 0,
11713
+ lastUsedAt: 0,
11714
+ topModels: []
11715
+ },
11716
+ turnIds: /* @__PURE__ */ new Set(),
11717
+ modelCalls: /* @__PURE__ */ new Map()
11718
+ };
11719
+ buckets.set(key, acc);
11720
+ }
11721
+ acc.row.calls += 1;
11722
+ acc.turnIds.add(call.turnId);
11723
+ if (!call.ok) acc.row.failed += 1;
11724
+ if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
11725
+ acc.modelCalls.set(call.model, (acc.modelCalls.get(call.model) ?? 0) + 1);
11726
+ if (call.usage !== void 0) {
11727
+ acc.row.inputTokens += call.usage.inputTokens;
11728
+ acc.row.outputTokens += call.usage.outputTokens;
11729
+ acc.row.cachedTokens += call.usage.cachedTokens;
11730
+ }
11731
+ }
11732
+ const out = [];
11733
+ for (const acc of buckets.values()) {
11734
+ const topModels = [...acc.modelCalls.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 2).map(([model]) => model);
11735
+ out.push({
11736
+ ...acc.row,
11737
+ turns: acc.turnIds.size,
11738
+ topModels
11739
+ });
11740
+ }
11741
+ out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.account.localeCompare(b.account));
11742
+ return out;
11743
+ }
11744
+ /**
11745
+ * The daily series, oldest first, limited to the most recent days.
11746
+ *
11747
+ * Sorted by key rather than insertion order because a retained window can
11748
+ * straddle a month boundary, and a chart whose axis jumps backwards reads as
11749
+ * corrupted data rather than as history.
11750
+ *
11751
+ * `failedTurns` is counted by DISTINCT turn id, not by summing per-call
11752
+ * failures: a turn that rotated three accounts and failed on all three is
11753
+ * still one failed turn, and the whole reason this module keeps turns and
11754
+ * calls apart is so that number stays honest in every place it appears.
11755
+ */
11756
+ days(failedTurnIds) {
11757
+ const buckets = /* @__PURE__ */ new Map();
11758
+ for (const call of this.samples) {
11759
+ const key = usageDayKey(call.at);
11760
+ let acc = buckets.get(key);
11761
+ if (acc === void 0) {
11762
+ acc = {
11763
+ day: key,
11764
+ calls: 0,
11765
+ inputTokens: 0,
11766
+ outputTokens: 0,
11767
+ failedTurnIds: /* @__PURE__ */ new Set()
11768
+ };
11769
+ buckets.set(key, acc);
11770
+ }
11771
+ acc.calls += 1;
11772
+ acc.inputTokens += call.usage?.inputTokens ?? 0;
11773
+ acc.outputTokens += call.usage?.outputTokens ?? 0;
11774
+ if (failedTurnIds.has(call.turnId)) acc.failedTurnIds.add(call.turnId);
11775
+ }
11776
+ return [...buckets.values()].sort((a, b) => a.day.localeCompare(b.day)).slice(-14).map((acc) => ({
11777
+ day: acc.day,
11778
+ calls: acc.calls,
11779
+ failedTurns: acc.failedTurnIds.size,
11780
+ inputTokens: acc.inputTokens,
11781
+ outputTokens: acc.outputTokens
11782
+ }));
11783
+ }
11784
+ };
11785
+ /**
11786
+ * Inspect one SSE frame's content, without its trailing separator.
11787
+ *
11788
+ * Returns undefined when no `data:` line parsed, which covers the `[DONE]`
11789
+ * sentinel, keep-alive comments and truncated JSON alike - a frame the forwarder
11790
+ * has no opinion about must come back untouched rather than guessed at.
11791
+ */
11792
+ function inspectSseFrame(frame) {
11793
+ for (const line of frame.split("\n")) {
11794
+ const trimmed = line.trim();
11795
+ if (!trimmed.startsWith("data:")) continue;
11796
+ const payload = trimmed.slice(5).trim();
11797
+ if (payload === "" || payload === "[DONE]") continue;
11798
+ let parsed;
11799
+ try {
11800
+ parsed = JSON.parse(payload);
11801
+ } catch {
11802
+ continue;
11803
+ }
11804
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
11805
+ const usage = parsed["usage"];
11806
+ if (usage === void 0 || usage === null) continue;
11807
+ return {
11808
+ parsed,
11809
+ usageOnly: isUsageOnlyFrame(parsed)
11810
+ };
11811
+ }
11812
+ }
11813
+ //#endregion
11814
+ //#region src/aggregate.ts
11815
+ /**
11816
+ * Aggregate lane: the 76 declared upstream providers, resolved per request and
11817
+ * forwarded to with the user's own key.
11818
+ *
11819
+ * WHY A SECOND LANE BESIDE THE POOL
11820
+ *
11821
+ * The pool serves exactly one family of gateways — WorkBuddy, CN and
11822
+ * international — from accounts the user already signed in on the desktop app.
11823
+ * This module serves the orthogonal case: a provider the user holds a key for,
11824
+ * reached at that provider's own endpoint, with no account rotation because
11825
+ * there is no account to rotate. Same card, same model picker, different wire.
11826
+ *
11827
+ * WHAT IT DELIBERATELY DOES NOT DO
11828
+ *
11829
+ * It does not invent endpoints. Every URL here comes from the registry record or
11830
+ * from a base-URL override the user typed; a provider whose endpoint cannot be
11831
+ * composed is reported as such rather than being probed at a guessed URL, because
11832
+ * "no listing" and "provider is down" must not read the same to the user.
11833
+ *
11834
+ * It also does not decide whether a provider is USABLE — that is the settings
11835
+ * layer's call (`providerAuthUsable`). This module resolves what a request
11836
+ * needs, and refuses with a message naming what is missing.
11837
+ *
11838
+ * KEYS
11839
+ *
11840
+ * Every string that can reach a message — a failure, a body snippet, a probe's
11841
+ * answer — passes through {@link redactAggregateSecret} first. Providers echo
11842
+ * rejected credentials back (a 401 body routinely repeats the key), and this
11843
+ * text is folded into messages the host writes to its durable session log.
11844
+ *
11845
+ * @module dsh-xdpool/aggregate
11846
+ */
11847
+ const WIRE_BY_FORMAT = Object.freeze({
11848
+ "openai-chat": "chat",
11849
+ "openai-responses": "responses",
11850
+ "anthropic-messages": "messages"
11851
+ });
11852
+ /**
11853
+ * Wire suffixes, needed only for the one record the registry gives no base URL
11854
+ * to (Azure names its endpoint per deployment, so `chatEndpoint` is null there
11855
+ * and there is no registry endpoint to read the suffix off).
11856
+ */
11857
+ const CHAT_SUFFIX = Object.freeze({
11858
+ "openai-chat": "/chat/completions",
11859
+ "openai-responses": "/responses",
11860
+ "anthropic-messages": "/messages"
11861
+ });
11862
+ /**
11863
+ * Capacities for a row the registry describes only by id.
11864
+ *
11865
+ * Deliberately conservative, and the reason is visible in the number: most
11866
+ * registry rows publish no context window at all. Publishing a plausible one
11867
+ * would put a fabricated field in front of the user — and unlike a display
11868
+ * label, a wrong window is a wrong capability, so DSH would hand the provider a
11869
+ * prompt it cannot accept.
11870
+ */
11871
+ const DEFAULT_CONTEXT_WINDOW = 131072;
11872
+ const DEFAULT_MAX_OUTPUT = 32768;
11873
+ /** Listing round budget. Generous for a small JSON document, short for a UI. */
11874
+ const LISTING_TIMEOUT_MS = 15e3;
11875
+ /** One turn's budget, matching the pool shim's idle ceiling. */
11876
+ const TURN_TIMEOUT_MS = 3e5;
11877
+ /** Sent when the registry does not declare one (kimchi, and most of the roster). */
11878
+ const USER_AGENT = "dsh-xdpool/2.1";
11879
+ /** How much of an upstream body is quoted back in a failure message. */
11880
+ const BODY_SNIPPET_CHARS = 400;
11881
+ /** A trimmed non-empty string, or ''. */
11882
+ function text$4(value) {
11883
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : "";
11884
+ }
11885
+ /** A positive finite number, or undefined. */
11886
+ function number(value) {
11887
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : void 0;
11888
+ }
11889
+ function isRecord$3(value) {
11890
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11891
+ }
11892
+ /**
11893
+ * One aggregate-lane failure, carrying the class the caller maps onto HTTP.
11894
+ *
11895
+ * `kind` reuses the pool's vocabulary (`UpstreamErrorKind` in `upstream.ts`) so
11896
+ * the shim's existing error→status table serves both lanes: a third-party 429
11897
+ * is a `soft_rate`, a 401 is a `session_dead`, and neither needs a second table
11898
+ * that could drift from the first.
11899
+ */
11900
+ var AggregateError = class extends Error {
11901
+ kind;
11902
+ status;
11903
+ constructor(kind, message, status = 0) {
11904
+ super(message);
11905
+ this.name = "AggregateError";
11906
+ this.kind = kind;
11907
+ this.status = status;
11908
+ }
11909
+ };
11910
+ /** Remove a credential from anything that may reach a message. */
11911
+ function redactAggregateSecret(value, apiKey) {
11912
+ let source = typeof value === "string" ? value : String(value ?? "");
11913
+ const key = text$4(apiKey);
11914
+ if (key === "") return source;
11915
+ const escaped = JSON.stringify(key).slice(1, -1);
11916
+ for (const needle of escaped === key ? [key] : [key, escaped]) if (source.includes(needle)) source = source.split(needle).join("[redacted]");
11917
+ return source;
11918
+ }
11919
+ /** A base URL a request can actually be sent to. */
11920
+ function hasPlaceholder(baseUrl) {
11921
+ return /\{[^}]*\}/.test(baseUrl);
11922
+ }
11923
+ /** The user's `baseUrl` override when present, else the registry's own. */
11924
+ function effectiveBaseUrl(provider, setting) {
11925
+ const override = text$4(setting.baseUrl);
11926
+ return (override !== "" ? override : text$4(provider.baseUrl)).replace(/\/+$/, "");
11927
+ }
11928
+ /**
11929
+ * The provider's listing URL, moved by a `baseUrl` override when — and only
11930
+ * when — the declared listing actually lives under the registry base.
11931
+ *
11932
+ * Three providers (cohere, deepinfra, ollama_cloud) publish their listing on
11933
+ * another path. Rewriting a URL that never pointed at the override would invent
11934
+ * one, and a guessed listing is worse than none.
11935
+ */
11936
+ function effectiveModelsUrl(provider, setting) {
11937
+ const declared = modelsEndpoint(provider);
11938
+ if (declared === null) return null;
11939
+ const override = text$4(setting.baseUrl).replace(/\/+$/, "");
11940
+ const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
11941
+ if (override === "" || registryBase === "" || !declared.startsWith(registryBase)) return declared;
11942
+ return `${override}${declared.slice(registryBase.length)}`;
11943
+ }
11944
+ /**
11945
+ * The full URL a turn is posted to; null when the provider has no dialable
11946
+ * endpoint (Azure without a user base URL, or a base still carrying the
11947
+ * registry's `{account_id}`-style placeholder).
11948
+ */
11949
+ function effectiveChatUrl(provider, setting) {
11950
+ const override = text$4(setting.baseUrl).replace(/\/+$/, "");
11951
+ const declared = chatEndpoint(provider);
11952
+ if (declared !== null) {
11953
+ const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
11954
+ const url = override === "" || registryBase === "" ? declared : `${override}${declared.slice(registryBase.length)}`;
11955
+ return hasPlaceholder(url) ? null : url;
11956
+ }
11957
+ const suffix = CHAT_SUFFIX[provider.format];
11958
+ if (override === "" || suffix === void 0 || hasPlaceholder(override)) return null;
11959
+ return `${override}${suffix}`;
11960
+ }
11961
+ /** The per-provider settings object, or `{}` when the store names none. */
11962
+ function settingFor(store, providerId) {
11963
+ return store.providers?.[providerId] ?? {};
11964
+ }
11965
+ /**
11966
+ * Resolve one provider's effective settings.
11967
+ *
11968
+ * Throws {@link AggregateError} with a `client` kind for every state the user
11969
+ * fixes in the settings page — an unknown id, a missing key, a placeholder base
11970
+ * URL. A `client` kind is deliberate: none of these is a transport fault, so
11971
+ * none of them should be retried against a different provider.
11972
+ *
11973
+ * `options.requireEnabled` exists for the DISCOVERY calls. Fetching a provider's
11974
+ * model list publishes nothing: it asks "does this endpoint answer, and with
11975
+ * what", and writing the answer back is a separate step the user also takes.
11976
+ * Gating it on the enable switch made a read-only button report
11977
+ * `"OpenCode Free" is not enabled in the aggregate settings` — a failure to
11978
+ * LOOK, dressed up as a failure to connect, on rows that carry no key field and
11979
+ * so offered no way to answer it. Discovery therefore does not require the
11980
+ * switch; publishing still does, in {@link buildAggregateCatalog} and
11981
+ * {@link resolveAggregateProvider} as called by the serving paths.
11982
+ */
11983
+ function resolveAggregateProvider(store, providerId, options = {}) {
11984
+ const id = text$4(providerId);
11985
+ const provider = id === "" ? null : getProvider(id);
11986
+ if (provider === null) throw new AggregateError("client", `the aggregate lane has no provider "${id === "" ? String(providerId) : id}"`);
11987
+ const setting = settingFor(store, provider.id);
11988
+ if (setting.enabled === false) throw new AggregateError("client", `"${provider.name}" is switched off in the aggregate settings`);
11989
+ if (!providerAuthUsable(provider, setting)) {
11990
+ if (provider.auth.kind === "oauth" || provider.auth.kind === "adc") throw new AggregateError("session_dead", `"${provider.name}" signs in with ${provider.auth.kind}, not with an API key, so the aggregate lane does not serve it`);
11991
+ const where = text$4(provider.auth.url) === "" ? "" : ` (the provider issues keys at ${provider.auth.url})`;
11992
+ throw new AggregateError("client", `"${provider.name}" needs an API key: paste one in the aggregate settings${where}`);
11993
+ }
11994
+ if (options.requireEnabled !== false && !providerEnabled(provider, setting)) throw new AggregateError("client", `"${provider.name}" is not enabled in the aggregate settings`);
11995
+ const baseUrl = effectiveBaseUrl(provider, setting);
11996
+ if (baseUrl === "") throw new AggregateError("client", `"${provider.name}" has no base URL of its own — set one in the aggregate settings`);
11997
+ if (hasPlaceholder(baseUrl)) throw new AggregateError("client", `"${provider.name}"'s base URL still carries a registry placeholder — set the real URL in the aggregate settings`);
11998
+ const chatUrl = effectiveChatUrl(provider, setting);
11999
+ if (chatUrl === null) throw new AggregateError("client", `"${provider.name}" has no usable ${provider.format} endpoint`);
12000
+ const apiKeys = orderApiKeys(providerApiKeys(setting), setting.distribution);
12001
+ const apiKey = apiKeys[0] ?? "";
12002
+ const headers = defaultHeaders(provider, apiKey);
12003
+ if (headers["user-agent"] === void 0) headers["user-agent"] = USER_AGENT;
12004
+ return {
12005
+ providerId: provider.id,
12006
+ providerName: provider.name,
12007
+ apiKey,
12008
+ /**
12009
+ * Every key, preferred first — what rotation reads.
12010
+ *
12011
+ * `apiKey` above stays entry 0 because the redaction helper and every existing
12012
+ * caller want one string. This is the field that makes several pasted keys
12013
+ * mean something: a key the vendor rate-limits is swapped for the next one
12014
+ * instead of failing the request.
12015
+ */
12016
+ apiKeys,
12017
+ baseUrl,
12018
+ format: provider.format,
12019
+ wire: WIRE_BY_FORMAT[provider.format],
12020
+ chatUrl,
12021
+ modelsUrl: effectiveModelsUrl(provider, setting),
12022
+ headers
12023
+ };
12024
+ }
12025
+ /**
12026
+ * The ids a provider should be served with, and where that list came from.
12027
+ *
12028
+ * `discovered` is the roster a fetch returned, `models` is the subset the user
12029
+ * kept, and the choice is applied LAST so it wins over both — otherwise pressing
12030
+ * "Fetch models" would silently undo the narrowing the card just saved.
12031
+ */
12032
+ function modelIdsFor(provider, setting) {
12033
+ const universe = modelChoicesFor(provider, setting);
12034
+ const source = rosterIsDiscovered(provider, setting) ? "discovered" : "static";
12035
+ const override = stringList(setting.models);
12036
+ if (override.length === 0) return {
12037
+ ids: universe,
12038
+ source
12039
+ };
12040
+ const available = new Set(universe);
12041
+ return {
12042
+ ids: override.filter((id) => available.has(id)),
12043
+ source
12044
+ };
12045
+ }
12046
+ /**
12047
+ * Whether the roster came from a live fetch rather than the registry.
12048
+ *
12049
+ * A pre-split install has `models` and no `discovered`, because until the
12050
+ * chooser existed the only writer of `models` WAS the fetch button. Those ids
12051
+ * are a roster, not a choice, and treating them as one would shrink the vendor
12052
+ * to whatever handful the registry happens to declare.
12053
+ */
12054
+ function rosterIsDiscovered(provider, setting) {
12055
+ if ((setting.discovered?.length ?? 0) > 0) return true;
12056
+ return stringList(setting.models).length > provider.models.length;
12057
+ }
12058
+ /** Every id a provider could offer: its roster, for the card's chooser. */
12059
+ function modelChoicesFor(provider, setting) {
12060
+ const discovered = stringList(setting.discovered);
12061
+ if (discovered.length === 0 && rosterIsDiscovered(provider, setting)) return stringList(setting.models);
12062
+ return discovered.length > 0 ? discovered : provider.models.map((model) => model.id);
12063
+ }
12064
+ /** A de-duplicated list of non-empty trimmed strings from an untrusted array. */
12065
+ function stringList(value) {
12066
+ if (!Array.isArray(value)) return [];
12067
+ const seen = /* @__PURE__ */ new Set();
12068
+ const ids = [];
12069
+ for (const raw of value) {
12070
+ const id = text$4(raw);
12071
+ if (id === "" || seen.has(id)) continue;
12072
+ seen.add(id);
12073
+ ids.push(id);
12074
+ }
12075
+ return ids;
12076
+ }
12077
+ /**
12078
+ * One catalog row.
12079
+ *
12080
+ * Capacities come from the registry's model row where it has them, and fall back
12081
+ * to this module's own defaults where it does not (most rows carry no context
12082
+ * window, and none carries an output ceiling — the registry records ids and
12083
+ * published capacities, not a serving contract).
12084
+ */
12085
+ function rowOf(provider, modelId, source) {
12086
+ const declared = provider.models.find((model) => model.id === modelId);
12087
+ const contextWindow = number(declared?.contextWindow) ?? DEFAULT_CONTEXT_WINDOW;
12088
+ const label = text$4(declared?.name) !== "" ? text$4(declared?.name) : modelId;
12089
+ return {
12090
+ id: providerModelId(provider.id, modelId),
12091
+ name: `${provider.name} ${label}`,
12092
+ providerId: provider.id,
12093
+ modelId,
12094
+ contextWindow,
12095
+ maxOutputTokens: Math.min(DEFAULT_MAX_OUTPUT, contextWindow),
12096
+ supportsImages: declared?.vision === true,
12097
+ wire: WIRE_BY_FORMAT[provider.format],
12098
+ source
12099
+ };
12100
+ }
12101
+ /**
12102
+ * Every model row the lane should offer, given the stored settings.
12103
+ *
12104
+ * A provider is skipped when it is switched off, when its credential flow this
12105
+ * lane cannot drive, or when its endpoint cannot be composed — a picker row that
12106
+ * can never answer is worse than no row, because it reads as "this provider is
12107
+ * broken" rather than "not set up yet".
12108
+ */
12109
+ function buildAggregateCatalog(store) {
12110
+ if (store.enabled === false) return [];
12111
+ const rows = [];
12112
+ for (const [providerId, setting] of Object.entries(store.providers ?? {})) {
12113
+ if (setting === void 0 || setting === null) continue;
12114
+ const provider = getProvider(providerId);
12115
+ if (provider === null) continue;
11261
12116
  if (!providerAuthUsable(provider, setting)) continue;
11262
12117
  if (!providerEnabled(provider, setting)) continue;
11263
12118
  if (effectiveChatUrl(provider, setting) === null) continue;
@@ -11538,30 +12393,80 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
11538
12393
  stream: true
11539
12394
  });
11540
12395
  const controller = new AbortController();
12396
+ /**
12397
+ * Every payload goes through here first, so the tracker sees the first byte and
12398
+ * any usage frame without the transport caring.
12399
+ *
12400
+ * Wrapped rather than instrumented at each `onData` call site: this function has
12401
+ * four of them, and one missed would silently under-report the turn's tokens.
12402
+ */
12403
+ const emit = (payload) => {
12404
+ tracker.markFirstByte();
12405
+ try {
12406
+ tracker.observe(JSON.parse(payload));
12407
+ } catch {}
12408
+ options.onData(payload);
12409
+ };
12410
+ /**
12411
+ * Measures this physical attempt for the ledger.
12412
+ *
12413
+ * Created before the request so `markFirstByte` reports from the moment the
12414
+ * caller asked, and kept in the outer scope so the `finally` can file the call
12415
+ * whether it succeeded or threw — a broken stream is a failed call from the
12416
+ * user's side of the wire, and the panel must count it rather than show nothing.
12417
+ */
12418
+ const tracker = new StreamTracker();
12419
+ let delivered = false;
11541
12420
  const timeoutMs = options.timeoutMs ?? TURN_TIMEOUT_MS;
11542
12421
  const onCallerAbort = () => controller.abort();
11543
12422
  options.signal?.addEventListener("abort", onCallerAbort, { once: true });
11544
12423
  try {
11545
- const response = await fetch(resolved.chatUrl, {
11546
- method: "POST",
11547
- headers: {
11548
- ...resolved.headers,
11549
- accept: "text/event-stream"
11550
- },
11551
- body: bodyText,
11552
- redirect: "error",
11553
- signal: controller.signal
11554
- });
11555
- const retryAfter = retryAfterMs(response);
11556
- if (!response.ok) {
12424
+ /**
12425
+ * POST, rotating the key when the vendor refuses the one we used.
12426
+ *
12427
+ * A provider holding several keys is a user who already knows a single key
12428
+ * gets rate-limited. Failing the turn on a 429/401 from key #1 wastes the very
12429
+ * thing they set up: the alternates. So a REFUSAL that looks like "this key,
12430
+ * not this request" retries with the next key.
12431
+ *
12432
+ * Only the request-credential classes retry — `soft_rate` (429) and
12433
+ * `session_dead` (401). A `client` (400), `not_found` (404), `hard_credit`
12434
+ * (402) or `server` (5xx) failure is about the request or the account balance
12435
+ * and would fail identically on every key, so retrying would only multiply the
12436
+ * wait. Keys are tried in order and each at most once, so the attempt count is
12437
+ * bounded by how many the user pasted.
12438
+ */
12439
+ const keys = resolved.apiKeys.length === 0 ? [""] : resolved.apiKeys;
12440
+ let response;
12441
+ let usedKey = resolved.apiKey;
12442
+ for (const [index, key] of keys.entries()) {
12443
+ usedKey = key;
12444
+ const headers = {
12445
+ ...resolved.headers,
12446
+ accept: "text/event-stream"
12447
+ };
12448
+ if (resolved.apiKey !== "") {
12449
+ for (const [name, value] of Object.entries(headers)) if (value === resolved.apiKey) headers[name] = key;
12450
+ }
12451
+ response = await fetch(resolved.chatUrl, {
12452
+ method: "POST",
12453
+ headers,
12454
+ body: bodyText,
12455
+ redirect: "error",
12456
+ signal: controller.signal
12457
+ });
12458
+ if (response.ok) break;
12459
+ const retryAfter = retryAfterMs(response);
11557
12460
  const refusal = await response.text().catch(() => "");
11558
- throw classifyUpstream(response.status, refusal, retryAfter, resolved.apiKey);
12461
+ const failure = classifyUpstream(response.status, refusal, retryAfter, key);
12462
+ if (!(failure.kind === "soft_rate" || failure.kind === "session_dead") || index === keys.length - 1) throw failure;
11559
12463
  }
11560
12464
  if (response.body === null) throw new AggregateError("server", "the provider returned no body", response.status);
11561
12465
  const head = await readSseHead(response.body, controller.signal, timeoutMs);
11562
12466
  if (head.shape === "empty") throw new AggregateError("server", "the provider returned no body", response.status);
11563
12467
  if (head.shape === "sse") {
11564
- for (const payload of head.payloads) options.onData(payload);
12468
+ for (const payload of head.payloads) emit(payload);
12469
+ delivered = true;
11565
12470
  return { status: response.status };
11566
12471
  }
11567
12472
  let raw = head.text;
@@ -11583,8 +12488,9 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
11583
12488
  if (/^\s*<(!doctype|html[\s>])/i.test(raw)) throw new AggregateError("client", `the provider's front proxy answered HTTP ${response.status} with an HTML error page`, response.status);
11584
12489
  throw new AggregateError("server", `unexpected non-stream response: ${redactAggregateSecret(raw, resolved.apiKey).slice(0, 200)}`, response.status);
11585
12490
  }
11586
- if (isRecord$3(payload) && (payload.error !== void 0 || payload.type === "error")) throw classifyUpstream(response.status, raw, retryAfter, resolved.apiKey);
11587
- for (const frame of synthesisedFrames(resolved.wire, payload, model)) options.onData(frame);
12491
+ if (isRecord$3(payload) && (payload.error !== void 0 || payload.type === "error")) throw classifyUpstream(response.status, raw, retryAfterMs(response), usedKey);
12492
+ for (const frame of synthesisedFrames(resolved.wire, payload, model)) emit(frame);
12493
+ delivered = true;
11588
12494
  return { status: response.status };
11589
12495
  } catch (error) {
11590
12496
  if (error instanceof AggregateError) throw error;
@@ -11594,6 +12500,18 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
11594
12500
  throw new AggregateError("server", `the request to ${resolved.providerName} failed: ${redactAggregateSecret(cause, resolved.apiKey)}`);
11595
12501
  } finally {
11596
12502
  options.signal?.removeEventListener("abort", onCallerAbort);
12503
+ tracker.finish(Date.now());
12504
+ options.usage?.recorder.record({
12505
+ channel: resolved.providerId,
12506
+ region: "",
12507
+ model,
12508
+ turnId: options.usage.turnId,
12509
+ at: Date.now(),
12510
+ ok: delivered,
12511
+ ...tracker.ttftMs === void 0 ? {} : { ttftMs: tracker.ttftMs },
12512
+ ...tracker.decodeMs === void 0 ? {} : { decodeMs: tracker.decodeMs },
12513
+ ...tracker.usage === void 0 ? {} : { usage: tracker.usage }
12514
+ });
11597
12515
  }
11598
12516
  }
11599
12517
  /**
@@ -12305,6 +13223,7 @@ function packCredentialToWorkBuddy(entry, document) {
12305
13223
  const expiresAtMs = expiryToMs(document.expires_at) || expiryToMs(entry.expiresAt);
12306
13224
  const nickname = text$3(document.nickname) ?? text$3(entry.nickname);
12307
13225
  const uid = text$3(document.user_id);
13226
+ const enterpriseId = text$3(document.enterprise_id);
12308
13227
  return {
12309
13228
  accessToken,
12310
13229
  refreshToken: text$3(document.refresh_token) ?? "",
@@ -12312,7 +13231,7 @@ function packCredentialToWorkBuddy(entry, document) {
12312
13231
  ...expiryToMs(document.refresh_expires_at) > 0 ? { refreshExpiresAtMs: expiryToMs(document.refresh_expires_at) } : {},
12313
13232
  ...nickname === void 0 ? {} : { nickname },
12314
13233
  ...uid === void 0 ? {} : { uid },
12315
- ...text$3(document.enterprise_id) === void 0 ? {} : { enterpriseId: text$3(document.enterprise_id) },
13234
+ ...enterpriseId === void 0 ? {} : { enterpriseId },
12316
13235
  domain,
12317
13236
  sourcePath: `channel-pack:${provider}:${refName}`
12318
13237
  };
@@ -12456,23 +13375,115 @@ function imageUrlOf(block, resolveImage, warnings) {
12456
13375
  if (direct.startsWith("http://") || direct.startsWith("https://")) return direct;
12457
13376
  warnings.push("image-dropped");
12458
13377
  }
12459
- /** Every tool result a message carries, in order. */
13378
+ /**
13379
+ * Drop tool calls nobody answered, and tool results nobody called.
13380
+ *
13381
+ * Every wire spells tool traffic as a PAIR: an assistant `tool_calls` entry with
13382
+ * an id, and a `role: 'tool'` message answering that same id. Upstream rejects a
13383
+ * conversation that breaks the pairing — and it rejects it EVERY TURN from then
13384
+ * on, because the offending messages stay in the history. A single interrupted
13385
+ * turn therefore makes a session permanently unusable, which is exactly what
13386
+ * users report as "this conversation broke and won't come back".
13387
+ *
13388
+ * Three shapes are repaired, all by DROPPING rather than inventing:
13389
+ *
13390
+ * 1. a `tool_calls` entry with no answer — the model called a tool, the turn
13391
+ * was interrupted, and the result never arrived. Keeping the call and
13392
+ * fabricating an answer would teach the model a lie; dropping the call
13393
+ * leaves the assistant text intact and the conversation legible.
13394
+ * 2. a `tool` message whose id answers no call — the call was pruned in an
13395
+ * earlier turn, or the result outlived its call.
13396
+ * 3. a `tool_calls` entry with an EMPTY name — it is "answered" by any result
13397
+ * but cannot be replayed: upstream validates the name. Responding side is
13398
+ * already safe (an unknown name is held back rather than streamed), but the
13399
+ * HISTORY would otherwise keep shipping it.
13400
+ *
13401
+ * Dropping is safe here only because the alternative is a hard 400 that kills
13402
+ * every subsequent turn. It is reported through `warnings` so the drop is not
13403
+ * silent — the same channel the request body already uses for other repairs.
13404
+ */
13405
+ function repairToolPairing(messages, warnings) {
13406
+ const called = /* @__PURE__ */ new Set();
13407
+ const answered = /* @__PURE__ */ new Set();
13408
+ for (const message of messages) {
13409
+ for (const call of toolCallsOf(message)) if (call.id !== "" && call.name !== "") called.add(call.id);
13410
+ for (const result of toolResultsOf(message)) if (result.callId !== "") answered.add(result.callId);
13411
+ }
13412
+ let droppedCalls = 0;
13413
+ let droppedResults = 0;
13414
+ const out = [];
13415
+ for (const message of messages) {
13416
+ if (message.role === "tool") {
13417
+ const id = text$2(message.tool_call_id ?? message.toolCallId ?? message.id);
13418
+ if (id !== "" && !called.has(id)) {
13419
+ droppedResults += 1;
13420
+ continue;
13421
+ }
13422
+ out.push(message);
13423
+ continue;
13424
+ }
13425
+ const calls = toolCallsOf(message);
13426
+ if (calls.length === 0) {
13427
+ out.push(message);
13428
+ continue;
13429
+ }
13430
+ const kept = calls.filter((call) => call.id !== "" && call.name !== "" && answered.has(call.id));
13431
+ if (kept.length === calls.length) {
13432
+ out.push(message);
13433
+ continue;
13434
+ }
13435
+ droppedCalls += calls.length - kept.length;
13436
+ if (kept.length === 0) {
13437
+ if (textOf(blocksOf(message.content)) === "") continue;
13438
+ const stripped = { ...message };
13439
+ delete stripped.tool_calls;
13440
+ delete stripped.toolCalls;
13441
+ out.push(stripped);
13442
+ continue;
13443
+ }
13444
+ out.push({
13445
+ ...message,
13446
+ tool_calls: kept
13447
+ });
13448
+ }
13449
+ if (droppedCalls > 0) warnings.push(`${String(droppedCalls)} unanswered tool call(s) were dropped so the conversation can continue`);
13450
+ if (droppedResults > 0) warnings.push(`${String(droppedResults)} orphaned tool result(s) were dropped so the conversation can continue`);
13451
+ return out;
13452
+ }
13453
+ /**
13454
+ * Every tool result a message carries, in order.
13455
+ *
13456
+ * The `role: 'tool'` case is the one that was silent and expensive: DSH's own
13457
+ * tool messages carry the RESULT as their content — there is no `tool-result`
13458
+ * block wrapper to find. Filtering for that wrapper therefore returned `[]`, and
13459
+ * the call sites skip a tool message with no results (`continue`), so the whole
13460
+ * result vanished from all three wires. The model then saw a conversation in
13461
+ * which it had called a tool and never learned the outcome, and re-issued the
13462
+ * same call forever — reported as "the tool returned no result" (#113).
13463
+ *
13464
+ * So: a tool message's content IS the result. Only a non-tool message is
13465
+ * searched for the wrapper blocks (that is the Anthropic-shaped spelling, where
13466
+ * a `user` turn can carry `tool-result` blocks).
13467
+ */
12460
13468
  function toolResultsOf(message) {
12461
- if (message.role !== "tool") {
12462
- const blocks = blocksOf(message.content).filter((block) => block.type === "tool-result");
12463
- if (blocks.length === 0) return [];
13469
+ if (message.role === "tool") {
13470
+ const callId = text$2(message.tool_call_id ?? message.toolCallId ?? message.id);
13471
+ const blocks = blocksOf(message.content);
12464
13472
  return [{
12465
- callId: text$2(message.tool_call_id ?? message.toolCallId ?? message.id),
12466
- content: blocks
13473
+ callId,
13474
+ content: blocks.length > 0 ? blocks : [{
13475
+ type: "text",
13476
+ text: typeof message.content === "string" ? message.content : ""
13477
+ }],
13478
+ ...message.is_error === true || message.isError === true ? { failed: true } : {}
12467
13479
  }];
12468
13480
  }
12469
- const out = [];
12470
13481
  const blocks = blocksOf(message.content).filter((block) => block.type === "tool-result");
12471
- for (const block of blocks) out.push({
12472
- callId: text$2(block.tool_use_id ?? block.toolCallId ?? message.tool_call_id ?? message.id),
12473
- content: [block]
12474
- });
12475
- return out;
13482
+ if (blocks.length === 0) return [];
13483
+ return [{
13484
+ callId: text$2(message.tool_call_id ?? message.toolCallId ?? message.id),
13485
+ content: blocks
13486
+ }];
12476
13487
  }
12477
13488
  /** The tool call a message's `tool_calls` array holds, when it holds one. */
12478
13489
  function toolCallsOf(message) {
@@ -12537,7 +13548,7 @@ function toolDefsFor(tools, wire) {
12537
13548
  */
12538
13549
  function requestBodyFor(wire, body, options) {
12539
13550
  const { modelId, resolveImage, warnings } = options;
12540
- const messages = Array.isArray(body.messages) ? body.messages.filter(isRecord$2) : [];
13551
+ const messages = repairToolPairing(Array.isArray(body.messages) ? body.messages.filter(isRecord$2) : [], warnings);
12541
13552
  const stream = body.stream === true;
12542
13553
  const reasoningEffort = text$2(body.reasoning_effort);
12543
13554
  const out = {
@@ -12713,7 +13724,8 @@ function claudeTurns(messages, resolveImage, warnings) {
12713
13724
  content: [{
12714
13725
  type: "tool_result",
12715
13726
  tool_use_id: result.callId,
12716
- content: body !== "" ? body : content.length > 0 ? content : "(no output)"
13727
+ content: body !== "" ? body : content.length > 0 ? content : "(no output)",
13728
+ ...result.failed === true ? { is_error: true } : {}
12717
13729
  }]
12718
13730
  });
12719
13731
  }
@@ -13838,7 +14850,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13838
14850
  result.usage = usage;
13839
14851
  }
13840
14852
  result.suppressedOnly = tools.suppressed();
13841
- result.finishReason = finish;
14853
+ if (finish !== void 0) result.finishReason = finish;
13842
14854
  return result;
13843
14855
  }
13844
14856
  /**
@@ -14124,464 +15136,135 @@ async function fetchFreeListing(options = {}) {
14124
15136
  const text = await response.text();
14125
15137
  if (!response.ok) throw new Error(`the free lane answered HTTP ${response.status}: ${text.slice(0, 200)}`);
14126
15138
  return JSON.parse(text);
14127
- } catch (error) {
14128
- if (callerAborted || options.signal?.aborted === true) throw new Error("request aborted");
14129
- if (error instanceof Error && error.name === "AbortError") throw new Error(`the free lane's listing did not answer within ${String(options.timeoutMs ?? 15e3)}ms`);
14130
- throw error instanceof Error ? error : new Error(String(error));
14131
- } finally {
14132
- clearTimeout(timer);
14133
- options.signal?.removeEventListener("abort", onCallerAbort);
14134
- }
14135
- }
14136
- /**
14137
- * The static roster, used until the first listing lands and again whenever one
14138
- * fails.
14139
- *
14140
- * An empty picker is the one outcome a user reads as "the plugin broke", so the
14141
- * lane ships with rows it can serve without ever having reached the network.
14142
- * These are the ids the gateway is known to serve免密 today; a row that later
14143
- * disappears costs one dead model entry, while an empty lane costs everything.
14144
- */
14145
- const FALLBACK_FREE_MODEL_IDS = Object.freeze([
14146
- "muse-spark-1.3-contributor-free",
14147
- "muse-spark-1.2-contributor-free",
14148
- "union-alpha",
14149
- "space-bunny-free",
14150
- "nemotron-3-ultra-free",
14151
- "deepseek-v4-flash-free",
14152
- "mimo-v2.6-flash-free",
14153
- "mimo-v2.5-free",
14154
- "jev-1.13-free"
14155
- ]);
14156
- /** Live free roster with a static fallback behind it. */
14157
- var FreeCatalog = class {
14158
- rows;
14159
- listeners = /* @__PURE__ */ new Set();
14160
- lastError;
14161
- lastUpdatedAt;
14162
- constructor(fallback = FALLBACK_FREE_MODEL_IDS) {
14163
- this.rows = Object.freeze(buildFreeCatalog(fallback));
14164
- }
14165
- current() {
14166
- return this.rows;
14167
- }
14168
- /** Why the last refresh failed, for display. Never a substitute for rows. */
14169
- error() {
14170
- return this.lastError;
14171
- }
14172
- /** When the live list last landed. */
14173
- updatedAt() {
14174
- return this.lastUpdatedAt;
14175
- }
14176
- /** The row behind a published id, or undefined. */
14177
- find(modelId) {
14178
- return this.rows.find((row) => row.modelId === modelId || row.id === modelId);
14179
- }
14180
- /** Whether anything is being served right now. */
14181
- hasRows() {
14182
- return this.rows.length > 0;
14183
- }
14184
- onChange(listener) {
14185
- this.listeners.add(listener);
14186
- return () => {
14187
- this.listeners.delete(listener);
14188
- };
14189
- }
14190
- /**
14191
- * Re-read the listing and notify when the roster actually changed.
14192
- *
14193
- * A failed refresh KEEPS the current rows and records the error. Dropping to
14194
- * empty here would make a network hiccup look like the models were deleted.
14195
- */
14196
- async refresh(options = {}) {
14197
- let next;
14198
- try {
14199
- next = Object.freeze(buildFreeCatalog(parseListing(await fetchFreeListing(options))));
14200
- } catch (error) {
14201
- this.lastError = error instanceof Error ? error.message : String(error);
14202
- if (this.rows.length === 0) this.rows = Object.freeze(buildFreeCatalog(FALLBACK_FREE_MODEL_IDS));
14203
- for (const listener of this.listeners) listener();
14204
- return this.rows;
14205
- }
14206
- if (next.length === 0) {
14207
- this.lastError = "the gateway listing held no免密 models";
14208
- for (const listener of this.listeners) listener();
14209
- return this.rows;
14210
- }
14211
- this.lastError = void 0;
14212
- this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
14213
- if (next.length === this.rows.length && next.every((row, index) => row.modelId === this.rows[index]?.modelId)) return this.rows;
14214
- this.rows = next;
14215
- for (const listener of this.listeners) listener();
14216
- return this.rows;
14217
- }
14218
- };
14219
- //#endregion
14220
- //#region src/stats.ts
14221
- /**
14222
- * Retained calls. Bounded on purpose: a busy week is thousands of calls, and a
14223
- * dashboard that grows without limit turns into a memory leak with a chart.
14224
- */
14225
- const USAGE_MAX_SAMPLES = 2e3;
14226
- /**
14227
- * Minimum total decoding time before a throughput figure is published.
14228
- *
14229
- * A one-token answer that arrives in 20ms would otherwise report a tps two
14230
- * orders of magnitude above anything a real turn reaches, and the panel would
14231
- * rank models by that artefact instead of by speed.
14232
- */
14233
- const MIN_DECODE_MS = 120;
14234
- /**
14235
- * A reported token count, or null when the key is absent / not a finite
14236
- * number.
14237
- *
14238
- * Zero is ACCEPTED: an empty turn really can cost nothing, and reporting that
14239
- * as "the gateway said nothing" would make the panel lie in the other
14240
- * direction.
14241
- */
14242
- function count(value) {
14243
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
14244
- }
14245
- function zero(value) {
14246
- return count(value) ?? 0;
14247
- }
14248
- function detailOf(value, key) {
14249
- if (typeof value !== "object" || value === null || Array.isArray(value)) return 0;
14250
- return zero(value[key]);
14251
- }
14252
- /**
14253
- * Read a gateway `usage` object, accepting both spellings OpenAI-compatible
14254
- * gateways use (`prompt_tokens` / `input_tokens`).
14255
- *
14256
- * Returns undefined when the document is not a usage report at all, so callers
14257
- * can tell "the gateway sent zeroes" from "there was nothing here".
14258
- */
14259
- function parseUsage(value) {
14260
- if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
14261
- const obj = value;
14262
- const input = count(obj["prompt_tokens"]) ?? count(obj["input_tokens"]);
14263
- const output = count(obj["completion_tokens"]) ?? count(obj["output_tokens"]);
14264
- if (input === null && output === null) return void 0;
14265
- return {
14266
- inputTokens: input ?? 0,
14267
- outputTokens: output ?? 0,
14268
- reasoningTokens: detailOf(obj["completion_tokens_details"], "reasoning_tokens"),
14269
- cachedTokens: detailOf(obj["prompt_tokens_details"], "cached_tokens")
14270
- };
14271
- }
14272
- /**
14273
- * True for the trailer frame a gateway sends to report usage.
14274
- *
14275
- * It is recognised by an EMPTY `choices` array beside a `usage` field. A frame
14276
- * that also carries content is a normal chunk that happens to mention usage,
14277
- * and dropping it would delete the user's answer - which is why the strip step
14278
- * downstream is allowed to remove exactly these and nothing else.
14279
- */
14280
- function isUsageOnlyFrame(parsed) {
14281
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
14282
- const obj = parsed;
14283
- if (obj["usage"] === void 0 || obj["usage"] === null) return false;
14284
- const choices = obj["choices"];
14285
- return Array.isArray(choices) && choices.length === 0;
14286
- }
14287
- /** Local calendar day of an instant, as `YYYY-MM-DD`. */
14288
- function usageDayKey(at) {
14289
- const d = new Date(at);
14290
- const month = String(d.getMonth() + 1).padStart(2, "0");
14291
- const day = String(d.getDate()).padStart(2, "0");
14292
- return `${d.getFullYear()}-${month}-${day}`;
14293
- }
14294
- /**
14295
- * Timing and usage captured while one stream is forwarded.
14296
- *
14297
- * Kept free of Node stream types so it can be unit tested with plain numbers;
14298
- * the shim owns the Transform that feeds it chunks.
14299
- */
14300
- var StreamTracker = class {
14301
- /** Milliseconds from request start to the first byte, once seen. */
14302
- ttftMs;
14303
- /** Milliseconds from first byte to the end of the stream, once finished. */
14304
- decodeMs;
14305
- /** Usage seen in any frame, if the gateway sent any. */
14306
- usage;
14307
- firstAt;
14308
- startedAt;
14309
- constructor(startedAt = Date.now()) {
14310
- this.startedAt = startedAt;
14311
- }
14312
- /** Record the first byte. Repeated calls keep the earliest. */
14313
- markFirstByte(at = Date.now()) {
14314
- if (this.firstAt !== void 0) return;
14315
- this.firstAt = at;
14316
- const ttft = at - this.startedAt;
14317
- if (ttft >= 0) this.ttftMs = ttft;
14318
- }
14319
- /** Record stream completion. Repeated calls keep the earliest. */
14320
- finish(at = Date.now()) {
14321
- if (this.decodeMs !== void 0) return;
14322
- if (this.firstAt === void 0) return;
14323
- const decode = at - this.firstAt;
14324
- if (decode >= 0) this.decodeMs = decode;
14325
- }
14326
- /** Fold a parsed frame's usage in; later frames overwrite, as the gateway intends. */
14327
- observe(parsed) {
14328
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
14329
- const usage = parseUsage(parsed["usage"]);
14330
- if (usage !== void 0) this.usage = usage;
15139
+ } catch (error) {
15140
+ if (callerAborted || options.signal?.aborted === true) throw new Error("request aborted");
15141
+ if (error instanceof Error && error.name === "AbortError") throw new Error(`the free lane's listing did not answer within ${String(options.timeoutMs ?? 15e3)}ms`);
15142
+ throw error instanceof Error ? error : new Error(String(error));
15143
+ } finally {
15144
+ clearTimeout(timer);
15145
+ options.signal?.removeEventListener("abort", onCallerAbort);
14331
15146
  }
14332
- };
15147
+ }
14333
15148
  /**
14334
- * A bounded window of recent calls, summarised on demand.
15149
+ * The static roster, used until the first listing lands and again whenever one
15150
+ * fails.
14335
15151
  *
14336
- * In-memory by design: this is a live panel, and persisting every prompt's
14337
- * token counts would turn a convenience into a record of what the user asked.
14338
- * A restart therefore starts the chart again, which the panel states rather
14339
- * than hides.
15152
+ * An empty picker is the one outcome a user reads as "the plugin broke", so the
15153
+ * lane ships with rows it can serve without ever having reached the network.
15154
+ * These are the ids the gateway is known to serve免密 today; a row that later
15155
+ * disappears costs one dead model entry, while an empty lane costs everything.
14340
15156
  */
14341
- var UsageRecorder = class {
14342
- samples = [];
14343
- limit;
14344
- constructor(limit = USAGE_MAX_SAMPLES) {
14345
- this.limit = limit > 0 ? limit : 1;
14346
- }
14347
- /** Retain one call, dropping the oldest once the window is full. */
14348
- record(call) {
14349
- this.samples.push(call);
14350
- while (this.samples.length > this.limit) this.samples.shift();
14351
- }
14352
- /** Samples currently retained. */
14353
- size() {
14354
- return this.samples.length;
14355
- }
14356
- /** Forget every retained call. */
14357
- reset() {
14358
- this.samples = [];
14359
- }
14360
- /** Aggregate the retained window. */
14361
- summary() {
14362
- const rows = /* @__PURE__ */ new Map();
14363
- const turns = /* @__PURE__ */ new Map();
14364
- let calls = 0;
14365
- let usageReported = false;
14366
- for (const call of this.samples) {
14367
- calls += 1;
14368
- if (call.usage !== void 0) usageReported = true;
14369
- const bucket = turns.get(call.turnId) ?? {
14370
- calls: 0,
14371
- succeeded: 0,
14372
- failed: 0
14373
- };
14374
- bucket.calls += 1;
14375
- if (call.ok) bucket.succeeded += 1;
14376
- else bucket.failed += 1;
14377
- turns.set(call.turnId, bucket);
14378
- const key = `${call.region}\u0000${call.model}`;
14379
- let acc = rows.get(key);
14380
- if (acc === void 0) {
14381
- acc = {
14382
- row: {
14383
- region: call.region,
14384
- model: call.model,
14385
- calls: 0,
14386
- turns: 0,
14387
- failed: 0,
14388
- recovered: 0,
14389
- inputTokens: 0,
14390
- outputTokens: 0,
14391
- reasoningTokens: 0,
14392
- cachedTokens: 0,
14393
- tps: null,
14394
- avgTtftMs: null,
14395
- lastUsedAt: 0
14396
- },
14397
- turnIds: /* @__PURE__ */ new Set(),
14398
- decodeMs: 0,
14399
- ttftTotal: 0,
14400
- ttftCount: 0
14401
- };
14402
- rows.set(key, acc);
14403
- }
14404
- acc.row.calls += 1;
14405
- acc.turnIds.add(call.turnId);
14406
- if (!call.ok) acc.row.failed += 1;
14407
- if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
14408
- if (call.ttftMs !== void 0) {
14409
- acc.ttftTotal += call.ttftMs;
14410
- acc.ttftCount += 1;
14411
- }
14412
- if (call.decodeMs !== void 0) acc.decodeMs += call.decodeMs;
14413
- if (call.usage !== void 0) {
14414
- acc.row.inputTokens += call.usage.inputTokens;
14415
- acc.row.outputTokens += call.usage.outputTokens;
14416
- acc.row.reasoningTokens += call.usage.reasoningTokens;
14417
- acc.row.cachedTokens += call.usage.cachedTokens;
14418
- }
14419
- }
14420
- const failedTurnIds = /* @__PURE__ */ new Set();
14421
- let recoveredTurns = 0;
14422
- for (const [turnId, bucket] of turns) if (bucket.failed > 0 && bucket.succeeded === 0) failedTurnIds.add(turnId);
14423
- else if (bucket.failed > 0) recoveredTurns += 1;
14424
- const out = [];
14425
- for (const acc of rows.values()) {
14426
- let recovered = 0;
14427
- for (const turnId of acc.turnIds) {
14428
- const bucket = turns.get(turnId);
14429
- if (bucket !== void 0 && bucket.failed > 0 && bucket.succeeded > 0) recovered += 1;
14430
- }
14431
- out.push({
14432
- ...acc.row,
14433
- turns: acc.turnIds.size,
14434
- recovered,
14435
- tps: acc.decodeMs >= MIN_DECODE_MS ? Math.round(acc.row.outputTokens / (acc.decodeMs / 1e3)) : null,
14436
- avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftTotal / acc.ttftCount) : null
14437
- });
14438
- }
14439
- out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.model.localeCompare(b.model));
14440
- return {
14441
- samples: this.samples.length,
14442
- turns: turns.size,
14443
- failedTurns: failedTurnIds.size,
14444
- recoveredTurns,
14445
- calls,
14446
- usageReported,
14447
- entries: this.samples.slice().reverse(),
14448
- rows: out,
14449
- accounts: this.accountRows(),
14450
- days: this.days(failedTurnIds)
14451
- };
14452
- }
15157
+ const FALLBACK_FREE_MODEL_IDS = Object.freeze([
15158
+ "muse-spark-1.3-contributor-free",
15159
+ "muse-spark-1.2-contributor-free",
15160
+ "union-alpha",
15161
+ "space-bunny-free",
15162
+ "nemotron-3-ultra-free",
15163
+ "deepseek-v4-flash-free",
15164
+ "mimo-v2.6-flash-free",
15165
+ "mimo-v2.5-free",
15166
+ "jev-1.13-free"
15167
+ ]);
15168
+ /** Live free roster with a static fallback behind it. */
15169
+ var FreeCatalog = class {
15170
+ rows;
15171
+ listeners = /* @__PURE__ */ new Set();
15172
+ lastError;
15173
+ lastUpdatedAt;
14453
15174
  /**
14454
- * One row per account, busiest first.
15175
+ * Whether this lane contributes models at all.
14455
15176
  *
14456
- * Samples that never named an account are grouped under an empty label
14457
- * rather than dropped: `account` is optional on `UsageCall`, so a caller that
14458
- * files a sample without one - a failure raised before any account was ever
14459
- * picked, say - would otherwise vanish from this view, and the account table
14460
- * would add up to less than the call total with nothing on screen to explain
14461
- * the gap. Every shim record site names its account; this branch exists so
14462
- * that a future one cannot quietly break the totals.
15177
+ * OFF unless the user turns it on, which is the opposite of every other lane
15178
+ * here and deliberate: the 免密 lane is a shared third-party endpoint with no
15179
+ * credential of its own, so it cannot be "configured" — it is either silently
15180
+ * present or absent. Left on by default it dropped a group into the model
15181
+ * picker that the user never asked for and could not remove: the aggregate
15182
+ * switch does not govern it (it is not an aggregate provider), so the only
15183
+ * way out was editing the file. Reported as "关不掉,应该默认是关闭的".
14463
15184
  *
14464
- * `topModels` keeps the two most-used ids rather than every one: this column
14465
- * answers "what does this account usually run", and a list of nine ids is not
14466
- * an answer to that question, it is just the other table again.
15185
+ * An explicit `true` is what enables it — `undefined` (a config written before
15186
+ * this switch existed, or a fresh install) stays off.
14467
15187
  */
14468
- accountRows() {
14469
- const buckets = /* @__PURE__ */ new Map();
14470
- for (const call of this.samples) {
14471
- const label = call.account ?? "";
14472
- const key = `${call.region}\u0000${label}`;
14473
- let acc = buckets.get(key);
14474
- if (acc === void 0) {
14475
- acc = {
14476
- row: {
14477
- account: label,
14478
- region: call.region,
14479
- calls: 0,
14480
- turns: 0,
14481
- failed: 0,
14482
- inputTokens: 0,
14483
- outputTokens: 0,
14484
- cachedTokens: 0,
14485
- lastUsedAt: 0,
14486
- topModels: []
14487
- },
14488
- turnIds: /* @__PURE__ */ new Set(),
14489
- modelCalls: /* @__PURE__ */ new Map()
14490
- };
14491
- buckets.set(key, acc);
14492
- }
14493
- acc.row.calls += 1;
14494
- acc.turnIds.add(call.turnId);
14495
- if (!call.ok) acc.row.failed += 1;
14496
- if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
14497
- acc.modelCalls.set(call.model, (acc.modelCalls.get(call.model) ?? 0) + 1);
14498
- if (call.usage !== void 0) {
14499
- acc.row.inputTokens += call.usage.inputTokens;
14500
- acc.row.outputTokens += call.usage.outputTokens;
14501
- acc.row.cachedTokens += call.usage.cachedTokens;
14502
- }
14503
- }
14504
- const out = [];
14505
- for (const acc of buckets.values()) {
14506
- const topModels = [...acc.modelCalls.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 2).map(([model]) => model);
14507
- out.push({
14508
- ...acc.row,
14509
- turns: acc.turnIds.size,
14510
- topModels
14511
- });
14512
- }
14513
- out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.account.localeCompare(b.account));
14514
- return out;
15188
+ laneEnabled = false;
15189
+ constructor(fallback = FALLBACK_FREE_MODEL_IDS) {
15190
+ this.rows = Object.freeze(buildFreeCatalog(fallback));
15191
+ }
15192
+ /** Turn the lane on or off; the roster readers all consult this. */
15193
+ setEnabled(enabled) {
15194
+ if (this.laneEnabled === enabled) return;
15195
+ this.laneEnabled = enabled;
15196
+ for (const listener of this.listeners) listener();
15197
+ }
15198
+ /** Whether the lane is contributing models right now. */
15199
+ enabled() {
15200
+ return this.laneEnabled;
14515
15201
  }
14516
15202
  /**
14517
- * The daily series, oldest first, limited to the most recent days.
14518
- *
14519
- * Sorted by key rather than insertion order because a retained window can
14520
- * straddle a month boundary, and a chart whose axis jumps backwards reads as
14521
- * corrupted data rather than as history.
15203
+ * The rows that may be OFFERED.
14522
15204
  *
14523
- * `failedTurns` is counted by DISTINCT turn id, not by summing per-call
14524
- * failures: a turn that rotated three accounts and failed on all three is
14525
- * still one failed turn, and the whole reason this module keeps turns and
14526
- * calls apart is so that number stays honest in every place it appears.
15205
+ * Empty when the lane is off. Callers that need the roster regardless (the
15206
+ * aggregate page's "free" filter, which lists what the lane COULD serve) use
15207
+ * {@link allRows} instead — so switching the lane off hides it from the model
15208
+ * picker without erasing it from the page that explains it.
14527
15209
  */
14528
- days(failedTurnIds) {
14529
- const buckets = /* @__PURE__ */ new Map();
14530
- for (const call of this.samples) {
14531
- const key = usageDayKey(call.at);
14532
- let acc = buckets.get(key);
14533
- if (acc === void 0) {
14534
- acc = {
14535
- day: key,
14536
- calls: 0,
14537
- inputTokens: 0,
14538
- outputTokens: 0,
14539
- failedTurnIds: /* @__PURE__ */ new Set()
14540
- };
14541
- buckets.set(key, acc);
14542
- }
14543
- acc.calls += 1;
14544
- acc.inputTokens += call.usage?.inputTokens ?? 0;
14545
- acc.outputTokens += call.usage?.outputTokens ?? 0;
14546
- if (failedTurnIds.has(call.turnId)) acc.failedTurnIds.add(call.turnId);
14547
- }
14548
- return [...buckets.values()].sort((a, b) => a.day.localeCompare(b.day)).slice(-14).map((acc) => ({
14549
- day: acc.day,
14550
- calls: acc.calls,
14551
- failedTurns: acc.failedTurnIds.size,
14552
- inputTokens: acc.inputTokens,
14553
- outputTokens: acc.outputTokens
14554
- }));
15210
+ current() {
15211
+ return this.laneEnabled ? this.rows : [];
15212
+ }
15213
+ /** Every row the lane knows about, offered or not. */
15214
+ allRows() {
15215
+ return this.rows;
15216
+ }
15217
+ /** Why the last refresh failed, for display. Never a substitute for rows. */
15218
+ error() {
15219
+ return this.lastError;
15220
+ }
15221
+ /** When the live list last landed. */
15222
+ updatedAt() {
15223
+ return this.lastUpdatedAt;
14555
15224
  }
14556
- };
14557
- /**
14558
- * Inspect one SSE frame's content, without its trailing separator.
14559
- *
14560
- * Returns undefined when no `data:` line parsed, which covers the `[DONE]`
14561
- * sentinel, keep-alive comments and truncated JSON alike - a frame the forwarder
14562
- * has no opinion about must come back untouched rather than guessed at.
14563
- */
14564
- function inspectSseFrame(frame) {
14565
- for (const line of frame.split("\n")) {
14566
- const trimmed = line.trim();
14567
- if (!trimmed.startsWith("data:")) continue;
14568
- const payload = trimmed.slice(5).trim();
14569
- if (payload === "" || payload === "[DONE]") continue;
14570
- let parsed;
15225
+ /** The row behind a published id, or undefined. */
15226
+ find(modelId) {
15227
+ return this.rows.find((row) => row.modelId === modelId || row.id === modelId);
15228
+ }
15229
+ /** Whether anything is being served right now. */
15230
+ hasRows() {
15231
+ return this.rows.length > 0;
15232
+ }
15233
+ onChange(listener) {
15234
+ this.listeners.add(listener);
15235
+ return () => {
15236
+ this.listeners.delete(listener);
15237
+ };
15238
+ }
15239
+ /**
15240
+ * Re-read the listing and notify when the roster actually changed.
15241
+ *
15242
+ * A failed refresh KEEPS the current rows and records the error. Dropping to
15243
+ * empty here would make a network hiccup look like the models were deleted.
15244
+ */
15245
+ async refresh(options = {}) {
15246
+ let next;
14571
15247
  try {
14572
- parsed = JSON.parse(payload);
14573
- } catch {
14574
- continue;
15248
+ next = Object.freeze(buildFreeCatalog(parseListing(await fetchFreeListing(options))));
15249
+ } catch (error) {
15250
+ this.lastError = error instanceof Error ? error.message : String(error);
15251
+ if (this.rows.length === 0) this.rows = Object.freeze(buildFreeCatalog(FALLBACK_FREE_MODEL_IDS));
15252
+ for (const listener of this.listeners) listener();
15253
+ return this.rows;
14575
15254
  }
14576
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
14577
- const usage = parsed["usage"];
14578
- if (usage === void 0 || usage === null) continue;
14579
- return {
14580
- parsed,
14581
- usageOnly: isUsageOnlyFrame(parsed)
14582
- };
15255
+ if (next.length === 0) {
15256
+ this.lastError = "the gateway listing held no免密 models";
15257
+ for (const listener of this.listeners) listener();
15258
+ return this.rows;
15259
+ }
15260
+ this.lastError = void 0;
15261
+ this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
15262
+ if (next.length === this.rows.length && next.every((row, index) => row.modelId === this.rows[index]?.modelId)) return this.rows;
15263
+ this.rows = next;
15264
+ for (const listener of this.listeners) listener();
15265
+ return this.rows;
14583
15266
  }
14584
- }
15267
+ };
14585
15268
  //#endregion
14586
15269
  //#region src/free-lane.ts
14587
15270
  /**
@@ -14692,6 +15375,7 @@ function createFreeLaneSource(catalog, logger, usageRecorder) {
14692
15375
  } finally {
14693
15376
  tracker.finish(Date.now());
14694
15377
  usageRecorder?.record({
15378
+ channel: "free",
14695
15379
  region: "free",
14696
15380
  model: modelId,
14697
15381
  account: "free",
@@ -15366,10 +16050,35 @@ function isFireHour(now, hours) {
15366
16050
  const { hour } = zonedParts(now);
15367
16051
  return hours.includes(Number(hour));
15368
16052
  }
15369
- /** Whether this account may be used: not switched off, not cooling. */
15370
- function eligible(account, now) {
16053
+ /**
16054
+ * Which account classes each job may touch.
16055
+ *
16056
+ * This replaced a single `global → false`, which was right about the growth
16057
+ * system and wrong as a blanket: it excluded a global account from EVERY job,
16058
+ * including the ones that are idempotent, cheap, and pay credits regardless of
16059
+ * region. The blanket also made the rule unfalsifiable — "global does nothing"
16060
+ * cannot be checked, whereas "the checkin job tries global accounts and here is
16061
+ * what came back" can.
16062
+ *
16063
+ * So the scope is per job, and the default for a job that is not named is `cn`:
16064
+ * a new job must opt in explicitly rather than inherit an accident.
16065
+ */
16066
+ const JOB_REGION_SCOPE = {
16067
+ report: ["cn"],
16068
+ tasks: ["cn"],
16069
+ streak: ["cn"],
16070
+ travel: ["cn"],
16071
+ compensation: ["cn"],
16072
+ checkin: ["cn", "global"]
16073
+ };
16074
+ /** Whether `job` may run against `region`; unnamed jobs stay CN. */
16075
+ function jobAllowsRegion(job, region) {
16076
+ return (JOB_REGION_SCOPE[job] ?? ["cn"]).includes(region);
16077
+ }
16078
+ /** Whether this account may be used for a job: not cooling, and right region. */
16079
+ function eligible(account, now, job) {
15371
16080
  if (account.cooldownUntilMs > now) return false;
15372
- if (regionOf(account.credential.domain) === "global") return false;
16081
+ if (job !== void 0 && !jobAllowsRegion(job, regionOf(account.credential.domain))) return false;
15373
16082
  return true;
15374
16083
  }
15375
16084
  const sleep$1 = (ms) => new Promise((resolve) => {
@@ -15836,8 +16545,22 @@ var WorkBuddyScheduler = class {
15836
16545
  let energy = 0;
15837
16546
  let claimed = 0;
15838
16547
  const detail = [];
16548
+ /**
16549
+ * Every item this run considered, with a status attached.
16550
+ *
16551
+ * `detail` stays as the human-readable log line; this is the same work in a
16552
+ * shape the card can GROUP and COUNT, so "3 tasks unclaimed — locked" is
16553
+ * distinguishable from "0 tasks, everything already collected".
16554
+ */
16555
+ const items = [];
15839
16556
  let progressNote;
15840
- const accounts = this.accountsInOrder();
16557
+ try {
16558
+ const revived = await this.pool.reviveBenched((credential) => this.client.fetchCredits(credential));
16559
+ for (const account of revived) detail.push(`复活 ${account.label}`);
16560
+ } catch (error) {
16561
+ this.logger.warn?.("automation: could not probe benched accounts:", error);
16562
+ }
16563
+ const accounts = this.accountsInOrder(kind);
15841
16564
  if (kind === "tasks") await this.sendEventChains(accounts);
15842
16565
  for (let index = 0; index < accounts.length; index++) {
15843
16566
  if (this.stopped) break;
@@ -15845,12 +16568,22 @@ var WorkBuddyScheduler = class {
15845
16568
  if (account === void 0) continue;
15846
16569
  try {
15847
16570
  switch (kind) {
15848
- case "checkin":
16571
+ case "checkin": {
16572
+ let alreadyDone = false;
16573
+ try {
16574
+ alreadyDone = (await this.client.fetchCheckinStatus(account.credential)).todayCheckedIn === true;
16575
+ } catch (error) {
16576
+ this.logger.warn?.(`automation checkin ${account.label}: status read failed, claiming anyway:`, error);
16577
+ }
16578
+ if (alreadyDone) {
16579
+ this.logger.info?.(`automation checkin ${account.label}: already done today (read, not claimed)`);
16580
+ break;
16581
+ }
15849
16582
  try {
15850
16583
  const claim = await this.client.claimDailyCheckin(account.credential);
15851
16584
  this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
15852
16585
  if (claim.credit > 0) {
15853
- if (claim.credit > 0) detail.push(`签到 +${claim.credit}`);
16586
+ detail.push(`签到 +${claim.credit}`);
15854
16587
  this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
15855
16588
  }
15856
16589
  } catch (error) {
@@ -15858,6 +16591,7 @@ var WorkBuddyScheduler = class {
15858
16591
  this.logger.info?.(`automation checkin ${account.label}: already done today`);
15859
16592
  }
15860
16593
  break;
16594
+ }
15861
16595
  case "report": {
15862
16596
  const days = await this.reportOne(account);
15863
16597
  this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
@@ -15869,6 +16603,7 @@ var WorkBuddyScheduler = class {
15869
16603
  energy += result.energy;
15870
16604
  claimed += result.claimed;
15871
16605
  detail.push(...result.titles);
16606
+ items.push(...result.items);
15872
16607
  this.claimableSeen += result.claimableCount;
15873
16608
  this.recordEarnings(account.id, today, {
15874
16609
  credit: result.credit,
@@ -15916,7 +16651,9 @@ var WorkBuddyScheduler = class {
15916
16651
  state.claimed = claimed;
15917
16652
  state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
15918
16653
  state.detail = detail;
15919
- state.progress = progressNote;
16654
+ state.items = items;
16655
+ if (progressNote === void 0) delete state.progress;
16656
+ else state.progress = progressNote;
15920
16657
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
15921
16658
  if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
15922
16659
  }
@@ -15930,12 +16667,16 @@ var WorkBuddyScheduler = class {
15930
16667
  *
15931
16668
  * Disabled accounts are excluded here rather than filtered by the caller so a
15932
16669
  * card switch takes effect on the next pass without any event plumbing.
16670
+ *
16671
+ * `job` narrows the set by region scope: the growth jobs are CN-only while
16672
+ * check-in runs on both gateways, so eligibility cannot be decided without
16673
+ * knowing which job is about to run.
15933
16674
  */
15934
- accountsInOrder() {
16675
+ accountsInOrder(job) {
15935
16676
  const now = Date.now();
15936
16677
  return this.pool.list().filter((account) => {
15937
16678
  if (this.pool.isDisabled(account.id)) return false;
15938
- return eligible(account, now);
16679
+ return eligible(account, now, job);
15939
16680
  });
15940
16681
  }
15941
16682
  /**
@@ -16061,10 +16802,55 @@ var WorkBuddyScheduler = class {
16061
16802
  * logs "could not be built" only for exceptions, and a task shape this code
16062
16803
  * does not understand is information, not a fault.
16063
16804
  */
16805
+ /**
16806
+ * Find the expert a task is about, by matching its title against the
16807
+ * marketplace.
16808
+ *
16809
+ * The marketplace listing is ~500 entries across `agent` and `team`, and this
16810
+ * only runs for a task whose deeplink carried no id — at most a handful per
16811
+ * run — so the cost is acceptable. It is not cached because a freshly
16812
+ * published expert must become findable without a restart.
16813
+ *
16814
+ * Matching rules, all of them strict on purpose:
16815
+ * - the task's title must appear IN the expert's name or profession, or the
16816
+ * expert's display name must appear in the title (both directions, since
16817
+ * the board and the marketplace word things differently);
16818
+ * - comparison ignores case, whitespace and the punctuation vendors sprinkle
16819
+ * through names;
16820
+ * - several matches means NO answer. Summoning the wrong agent scores 0, so a
16821
+ * guess costs an upstream call and still fails — worse than declining.
16822
+ */
16823
+ async expertIdByKeyword(credential, task) {
16824
+ const normalise = (value) => value.toLowerCase().replace(/[\s_\-·::,,.。()()【】[\]「」]/gu, "");
16825
+ const wanted = normalise(task.title);
16826
+ if (wanted === "") return void 0;
16827
+ let candidates;
16828
+ try {
16829
+ const [agents, teams] = await Promise.all([this.client.marketExpertList(credential, "agent"), this.client.marketExpertList(credential, "team")]);
16830
+ candidates = [...agents, ...teams];
16831
+ } catch (error) {
16832
+ this.logger.warn?.(`automation events ${task.taskCode}: marketplace lookup failed:`, error);
16833
+ return;
16834
+ }
16835
+ const matches = candidates.filter((candidate) => {
16836
+ const name = normalise(candidate.displayName ?? "");
16837
+ const profession = normalise(candidate.profession ?? "");
16838
+ if (name === "" && profession === "") return false;
16839
+ return name !== "" && (name.includes(wanted) || wanted.includes(name)) || profession !== "" && (profession.includes(wanted) || wanted.includes(profession));
16840
+ });
16841
+ if (matches.length !== 1) {
16842
+ if (matches.length > 1) this.logger.info?.(`automation events ${task.taskCode}: ${String(matches.length)} marketplace experts match "${task.title}", not guessing`);
16843
+ return;
16844
+ }
16845
+ const found = matches[0];
16846
+ if (found === void 0) return void 0;
16847
+ this.logger.info?.(`automation events ${task.taskCode}: resolved expertId ${found.expertId} by keyword "${task.title}"`);
16848
+ return found.expertId;
16849
+ }
16064
16850
  async taskExpertChains(credential, task) {
16065
- const expertId = expertIdFromJumpUrl(task.jumpUrl);
16851
+ const expertId = expertIdFromJumpUrl(task.jumpUrl) ?? await this.expertIdByKeyword(credential, task);
16066
16852
  if (expertId === void 0) {
16067
- this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url, skipping`);
16853
+ this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url and no marketplace match, skipping`);
16068
16854
  return [];
16069
16855
  }
16070
16856
  const expert = expertFromTask(expertId, task.title);
@@ -16149,21 +16935,41 @@ var WorkBuddyScheduler = class {
16149
16935
  const credential = account.credential;
16150
16936
  const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
16151
16937
  if (open.length > 0) await this.client.acceptTasks(credential, open);
16152
- const claimable = (await this.client.listTasks(credential)).filter((task) => task.claimable && !task.locked);
16938
+ const refreshed = await this.client.listTasks(credential);
16939
+ const claimable = refreshed.filter((task) => task.claimable && !task.locked);
16153
16940
  let credit = 0;
16154
16941
  let energy = 0;
16155
16942
  let claimed = 0;
16156
16943
  const titles = [];
16944
+ const items = [];
16945
+ for (const task of refreshed) if (task.locked === true || task.claimable !== true) items.push({
16946
+ title: task.title === "" ? task.taskCode : task.title,
16947
+ status: "skipped",
16948
+ note: task.locked === true ? "locked" : task.acceptStatus === "not_accepted" ? "not accepted" : "not scored yet"
16949
+ });
16157
16950
  for (const task of claimable) {
16158
16951
  if (this.stopped) break;
16159
16952
  const reward = await this.client.claimTaskReward(credential, task.taskCode);
16160
16953
  credit += reward.credit;
16161
16954
  energy += reward.energy;
16955
+ const label = task.title === "" ? task.taskCode : task.title;
16162
16956
  if (reward.credit > 0 || reward.energy > 0) {
16163
16957
  claimed++;
16164
16958
  titles.push(task.title);
16959
+ items.push({
16960
+ title: label,
16961
+ status: "claimed",
16962
+ ...reward.credit > 0 ? { credit: reward.credit } : {},
16963
+ ...reward.energy > 0 ? { energy: reward.energy } : {}
16964
+ });
16165
16965
  this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
16166
- } else this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
16966
+ } else {
16967
+ items.push({
16968
+ title: label,
16969
+ status: "already"
16970
+ });
16971
+ this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
16972
+ }
16167
16973
  if (this.delayMs > 0 && !this.stopped) await sleep$1(this.delayMs);
16168
16974
  }
16169
16975
  return {
@@ -16171,7 +16977,8 @@ var WorkBuddyScheduler = class {
16171
16977
  credit,
16172
16978
  energy,
16173
16979
  claimableCount: claimable.length,
16174
- titles
16980
+ titles,
16981
+ items
16175
16982
  };
16176
16983
  }
16177
16984
  /**
@@ -16340,6 +17147,7 @@ const PROVIDER_NOTES_ZH = Object.freeze({
16340
17147
  "kimi_code": "Kimi Code 订阅,模型 id 取自 9router 的 kimi 注册表",
16341
17148
  "lightning": "Lightning AI 的 Model APIs 网关",
16342
17149
  "llamacpp": "本地服务器,模型列表取决于用户已加载的模型",
17150
+ "custom_openai": "自定义 OpenAI 兼容端点:vLLM / LiteLLM / one-api / 自建网关,填 base URL 和 key 后拉取模型",
16343
17151
  "llm7": "default/fast/pro 别名被固定,只留流式工具模型",
16344
17152
  "lmstudio": "本地服务器,模型列表取决于用户已加载的模型",
16345
17153
  "minimax": "推理内容从正文流中单独拆出,语音与图像 id 已剔除",
@@ -16699,6 +17507,24 @@ function parseAggregatePatch(body) {
16699
17507
  };
16700
17508
  if (typeof body["enabled"] === "boolean") patch.enabled = body["enabled"];
16701
17509
  if (typeof body["apiKey"] === "string") patch.apiKey = body["apiKey"];
17510
+ if (Array.isArray(body["apiKeys"])) {
17511
+ const seen = /* @__PURE__ */ new Set();
17512
+ const apiKeys = [];
17513
+ for (const raw of body["apiKeys"]) {
17514
+ if (typeof raw !== "string") continue;
17515
+ const key = raw.trim();
17516
+ if (key === "" || seen.has(key)) continue;
17517
+ seen.add(key);
17518
+ apiKeys.push(key);
17519
+ }
17520
+ patch.apiKeys = apiKeys;
17521
+ }
17522
+ if (typeof body["distribution"] === "string") {
17523
+ const strategy = body["distribution"].trim();
17524
+ if (!isDistributionStrategy(strategy)) return void 0;
17525
+ patch.distribution = strategy;
17526
+ }
17527
+ if (typeof body["appendApiKey"] === "string") patch.appendApiKey = body["appendApiKey"];
16702
17528
  if (typeof body["baseUrl"] === "string") patch.baseUrl = body["baseUrl"];
16703
17529
  if (Array.isArray(body["models"])) {
16704
17530
  const seen = /* @__PURE__ */ new Set();
@@ -16760,7 +17586,9 @@ function aggregateStatusOf(store, models) {
16760
17586
  models: exposed,
16761
17587
  availableModels: universe,
16762
17588
  discovered: (setting.discovered?.length ?? 0) > 0,
16763
- ...setting.addedAt === void 0 ? {} : { addedAt: setting.addedAt }
17589
+ ...setting.addedAt === void 0 ? {} : { addedAt: setting.addedAt },
17590
+ keyCount: providerApiKeys(setting).length,
17591
+ ...setting.distribution === void 0 ? {} : { distribution: setting.distribution }
16764
17592
  });
16765
17593
  }
16766
17594
  return {
@@ -16928,7 +17756,10 @@ async function poolWebStatus(deps, region = "cn") {
16928
17756
  creditReserves: deps.pool.creditReservesInOrder(),
16929
17757
  ignored: deps.ignoredAccounts?.() ?? [],
16930
17758
  catalogSource: deps.catalogs[region].currentSource(),
16931
- ...deps.catalogs[region].catalogUpdatedAt() === void 0 ? {} : { catalogUpdatedAt: deps.catalogs[region].catalogUpdatedAt() },
17759
+ ...(() => {
17760
+ const updatedAt = deps.catalogs[region].catalogUpdatedAt();
17761
+ return updatedAt === void 0 ? {} : { catalogUpdatedAt: updatedAt };
17762
+ })(),
16932
17763
  ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) },
16933
17764
  ...deps.pool.skippedFilesInOrder().length === 0 ? {} : { skippedFiles: deps.pool.skippedFilesInOrder().map((entry) => ({
16934
17765
  file: basename(entry.path),
@@ -17427,12 +18258,12 @@ function registerPoolStatusRoute(ctx, deps) {
17427
18258
  const disposeCombos = ctx.webServer.register({
17428
18259
  kind: "exact",
17429
18260
  path: POOL_COMBOS_PATH,
17430
- handler: (req, res) => {
18261
+ handler: async (req, res) => {
17431
18262
  if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
17432
18263
  if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
17433
18264
  if (deps.combosDocument === void 0) return json(res, 503, { error: "the routing chains are not available in this build" });
17434
18265
  try {
17435
- json(res, 200, deps.combosDocument());
18266
+ json(res, 200, await deps.combosDocument());
17436
18267
  } catch (error) {
17437
18268
  json(res, 500, { error: safeMessage(error) });
17438
18269
  }
@@ -17453,8 +18284,10 @@ function registerPoolStatusRoute(ctx, deps) {
17453
18284
  if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
17454
18285
  if (deps.saveCombos === void 0) return json(res, 503, { error: "the routing chains are not available in this build" });
17455
18286
  try {
17456
- const raw = (await readJsonBody(req))["combos"];
17457
- json(res, 200, await deps.saveCombos(Array.isArray(raw) ? raw : []));
18287
+ const body = await readJsonBody(req);
18288
+ const raw = Array.isArray(body) ? body : body["combos"];
18289
+ if (!Array.isArray(raw)) return json(res, 400, { error: "expected {combos: [...]} (or a bare array from older clients); refusing to replace the chain table with an empty one" });
18290
+ json(res, 200, await deps.saveCombos(raw));
17458
18291
  } catch (error) {
17459
18292
  json(res, 500, { error: safeMessage(error) });
17460
18293
  }
@@ -17579,6 +18412,46 @@ function recordStartupNote(line) {
17579
18412
  } catch {}
17580
18413
  return text;
17581
18414
  }
18415
+ //#endregion
18416
+ //#region src/model-switch-bridge.ts
18417
+ /**
18418
+ * The lane id the host's picker uses for each region.
18419
+ *
18420
+ * The same two ids `POOL_OWNED_PROVIDERS` exempts inside the pack, and the same
18421
+ * two `REGION_CHANNEL_BY_REGION` maps in the card. Kept here as a literal
18422
+ * because this module is imported by the host half only, which must not pull in
18423
+ * the browser bundle's constants.
18424
+ */
18425
+ const LANE_BY_REGION = {
18426
+ cn: "buddy",
18427
+ global: "workbuddy"
18428
+ };
18429
+ /**
18430
+ * Replace the selection and mirror it where the pack can see it.
18431
+ *
18432
+ * The mirror is `globalThis.__xdpoolLaneSelection`, which `pack.js` reads in its
18433
+ * `[xdpool-local] laneAllowedIds` helper. A global rather than a getter on the
18434
+ * account pool because the pack's adapters receive `accountPool` — this
18435
+ * plugin's own object — and nothing else that this module could hang a function
18436
+ * on without editing ten constructors.
18437
+ */
18438
+ function applyLaneSelection(next) {
18439
+ globalThis.__xdpoolLaneSelection = next;
18440
+ }
18441
+ /**
18442
+ * Derive the lane keyed selection from the two settings documents.
18443
+ *
18444
+ * Split out from the caller so the mapping is testable without a host: the one
18445
+ * thing worth asserting is that an ABSENT setting becomes `undefined` (no
18446
+ * restriction) while an explicitly EMPTY `enabledModelIds` stays an empty array
18447
+ * (nothing allowed).
18448
+ */
18449
+ function laneSelectionFrom(cn, global) {
18450
+ return {
18451
+ [LANE_BY_REGION.cn]: cn,
18452
+ [LANE_BY_REGION.global]: global
18453
+ };
18454
+ }
17582
18455
  /** Hard ceiling on the whole probe, so a hung upstream cannot wedge the card. */
17583
18456
  const PROBE_TIMEOUT_MS = 3e4;
17584
18457
  /**
@@ -17901,9 +18774,7 @@ async function compactWithSummary(messages, options, deps, signal) {
17901
18774
  const first = compactMessages(messages, options);
17902
18775
  if (!first.changed) return {
17903
18776
  messages: first.messages,
17904
- tokens: first.tokens,
17905
- summary: void 0,
17906
- skipped: void 0
18777
+ tokens: first.tokens
17907
18778
  };
17908
18779
  const summaryBudget = Math.max(256, Math.floor(options.budget / 4));
17909
18780
  let toSummarize = first.dropped;
@@ -17932,8 +18803,8 @@ async function compactWithSummary(messages, options, deps, signal) {
17932
18803
  }
17933
18804
  if (summary === void 0) return {
17934
18805
  messages: first.messages,
17935
- skipped,
17936
- tokens: first.tokens
18806
+ tokens: first.tokens,
18807
+ ...skipped === void 0 ? {} : { skipped }
17937
18808
  };
17938
18809
  const withSummary = injectSummary(first.messages, summary);
17939
18810
  if (estimateMessagesTokens(withSummary) > options.budget) {
@@ -18353,6 +19224,19 @@ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
18353
19224
  "localhost",
18354
19225
  "[::1]"
18355
19226
  ]);
19227
+ /**
19228
+ * How long one chain member may hold the request before it is given up on.
19229
+ *
19230
+ * The caller's own signal covers "the user cancelled", not "this provider decided
19231
+ * to keep the socket open". Without a per-member deadline a single non-answering
19232
+ * upstream blocks the request forever, and the chain's whole purpose — try the
19233
+ * next member — never runs.
19234
+ *
19235
+ * 180s is the value the reference router settled on: long enough for a slow
19236
+ * reasoning model to emit its first token, short enough to give up inside one
19237
+ * turn rather than leaving the user staring at a spinner.
19238
+ */
19239
+ const MEMBER_TIMEOUT_MS = 18e4;
18356
19240
  /** HTTP status each upstream failure class surfaces as. */
18357
19241
  const KIND_STATUS = {
18358
19242
  hard_credit: 402,
@@ -18362,6 +19246,27 @@ const KIND_STATUS = {
18362
19246
  server: 502,
18363
19247
  client: 400
18364
19248
  };
19249
+ /**
19250
+ * HTTP status for each "no account available" reason.
19251
+ *
19252
+ * The reason is already a complete diagnosis — `unavailableReason` separates
19253
+ * cooling from a drained reserve from a switched-off account from an empty pool
19254
+ * — and each of those needs a different user action. Flattening them into one
19255
+ * status throws that away: 401 universally reads as "invalid API key", so a
19256
+ * reserve or a disabled account sent the reader to re-authenticate over a
19257
+ * setting they had chosen themselves (#115).
19258
+ *
19259
+ * A reason this table does not know falls back to 503 at the call site rather
19260
+ * than 401: "this lane cannot serve you right now" is a true statement about an
19261
+ * unfamiliar state, while "bad key" is a specific and probably false one.
19262
+ */
19263
+ const REASON_STATUS = {
19264
+ cooling: 429,
19265
+ session_dead: 401,
19266
+ reserve: 402,
19267
+ disabled: 403,
19268
+ empty: 503
19269
+ };
18365
19270
  function hostnameOfHost(host) {
18366
19271
  let hostname = host.trim().toLowerCase();
18367
19272
  if (hostname.startsWith("[")) {
@@ -18839,10 +19744,22 @@ function createWorkBuddyShim(options) {
18839
19744
  }
18840
19745
  const body = withBareModelId(raw, parsed.modelId);
18841
19746
  const frames = [];
19747
+ /**
19748
+ * One id per user request, so a retry across keys counts as ONE turn.
19749
+ *
19750
+ * The key-rotation loop inside the forwarder files each physical attempt, and
19751
+ * they must share this id or the panel would report "3 turns" for one question
19752
+ * that happened to trip a rate limit. Same spelling the pool lane uses.
19753
+ */
19754
+ const aggregateTurnId = randomBytes(8).toString("hex");
18842
19755
  try {
18843
19756
  await forwardAggregateStreamed(resolved, parsed.modelId, body, {
18844
19757
  signal,
18845
- onData: (payload) => frames.push(payload)
19758
+ onData: (payload) => frames.push(payload),
19759
+ ...usageRecorder === void 0 ? {} : { usage: {
19760
+ recorder: usageRecorder,
19761
+ turnId: aggregateTurnId
19762
+ } }
18846
19763
  });
18847
19764
  } catch (error) {
18848
19765
  if (res.headersSent) {
@@ -19043,102 +19960,249 @@ function createWorkBuddyShim(options) {
19043
19960
  }
19044
19961
  const attempts = [];
19045
19962
  let last;
19963
+ /**
19964
+ * Why the chain moved on from each member, in order.
19965
+ *
19966
+ * A chain hides its own machinery: the user sees one answer from an
19967
+ * unspecified member, and when the answer is worse/different/slower than
19968
+ * expected there is nothing on screen to explain it. The whole point of
19969
+ * reading a fallback trace is answering "why did it switch, and where did it
19970
+ * land" — so every skip records the member, the failure class and the wire
19971
+ * status, and the finished turn logs the sequence once.
19972
+ *
19973
+ * Logged, not returned: the caller asked for a completion, and a response
19974
+ * shape invented for diagnostics would break every client that parses it.
19975
+ */
19976
+ const switches = [];
19977
+ const noteSwitch = (member, kind, status) => {
19978
+ switches.push(`${member} → ${kind} (${String(status)})`);
19979
+ logger?.warn?.(`dsh-xdpool: chain ${row.id} skipped ${member}: ${kind} (${String(status)}); trying next`);
19980
+ };
19046
19981
  const conversationKey = conversationKeyOf(raw);
19047
19982
  for (const member of members) {
19048
19983
  if (signal.aborted) return;
19049
19984
  attempts.push(member.modelId);
19050
- if (!member.aggregate) {
19051
- const { account, reserveExempt } = await acquireForRequest(member.modelId, conversationKey, signal);
19052
- if (account === void 0) {
19053
- const why = pool.unavailableReason(member.modelId, region, reserveExempt);
19985
+ const memberController = new AbortController();
19986
+ const memberSignal = memberController.signal;
19987
+ const relayAbort = () => {
19988
+ memberController.abort();
19989
+ };
19990
+ if (signal.aborted) relayAbort();
19991
+ else signal.addEventListener("abort", relayAbort, { once: true });
19992
+ const memberTimer = setTimeout(() => {
19993
+ memberController.abort();
19994
+ }, MEMBER_TIMEOUT_MS);
19995
+ memberTimer.unref?.();
19996
+ /** Release this member's timer and listener. Every exit path calls it. */
19997
+ const releaseMember = () => {
19998
+ clearTimeout(memberTimer);
19999
+ signal.removeEventListener("abort", relayAbort);
20000
+ };
20001
+ try {
20002
+ /**
20003
+ * A 扫码登录 channel member, served by the HOST's adapter registry.
20004
+ *
20005
+ * This is the branch that makes a chain able to name TRAE / Qoder /
20006
+ * CodeBuddy at all. It goes through `channels.turn`, the same seam the
20007
+ * single-model path uses, so the credential, endpoint and wire dialect are
20008
+ * the host's problem rather than ours — and the model a chain reaches is
20009
+ * literally the one the picker shows.
20010
+ *
20011
+ * Frames are BUFFERED like every other member (`frames.push`, written only on
20012
+ * success): "retry only before any visible content" is what the chain
20013
+ * promises, and streaming straight to the socket here would leave two models'
20014
+ * text spliced together on screen when a later member answered.
20015
+ */
20016
+ if (member.host === true) {
20017
+ if (channels === void 0) {
20018
+ last = {
20019
+ kind: "combo_config",
20020
+ status: 503,
20021
+ message: `member ${member.modelId} needs the login-channel lane, which this build does not mount`
20022
+ };
20023
+ noteSwitch(member.modelId, "combo_config", 503);
20024
+ continue;
20025
+ }
20026
+ const channelTarget = channels.resolve(member.modelId);
20027
+ if (channelTarget === void 0) {
20028
+ last = {
20029
+ kind: "combo_config",
20030
+ status: 400,
20031
+ message: `member ${member.modelId} is not a resolvable channel model id`
20032
+ };
20033
+ noteSwitch(member.modelId, "combo_config", 400);
20034
+ continue;
20035
+ }
20036
+ const memberCreated = Math.floor(Date.now() / 1e3);
20037
+ const memberCompletionId = `chatcmpl-xdpool-${randomBytes(6).toString("base64url")}`;
20038
+ const hostFrames = [];
20039
+ const frameOf = (delta, reason) => JSON.stringify({
20040
+ id: memberCompletionId,
20041
+ object: "chat.completion.chunk",
20042
+ created: memberCreated,
20043
+ model: channelTarget.modelId,
20044
+ choices: [{
20045
+ index: 0,
20046
+ delta,
20047
+ finish_reason: reason
20048
+ }],
20049
+ usage: null
20050
+ });
20051
+ try {
20052
+ let finishReason = "stop";
20053
+ for await (const event of channels.turn({
20054
+ channel: channelTarget.channel,
20055
+ modelId: channelTarget.modelId,
20056
+ body: JSON.parse(withBareModelId(raw, channelTarget.modelId)),
20057
+ signal: memberSignal
20058
+ })) if (event.kind === "usage") {} else if (event.kind === "done") {
20059
+ if (typeof event.finishReason === "string" && event.finishReason !== "") finishReason = event.finishReason;
20060
+ } else {
20061
+ const delta = event.delta;
20062
+ if (delta["content"] !== void 0 || delta["reasoning_content"] !== void 0 || Array.isArray(delta["tool_calls"])) {}
20063
+ hostFrames.push(frameOf(delta, null));
20064
+ }
20065
+ /**
20066
+ * An empty answer is still an ANSWER.
20067
+ *
20068
+ * The reference router settled this and it is right: a model that replied
20069
+ * with nothing visible has responded, so falling through to the next
20070
+ * member would silently substitute a different model over a legitimate
20071
+ * (if unhelpful) result. The terminal frame is therefore always emitted,
20072
+ * whether or not anything preceded it.
20073
+ */
20074
+ hostFrames.push(frameOf({}, finishReason));
20075
+ } catch (error) {
20076
+ const message = error instanceof Error ? error.message : String(error);
20077
+ const kind = error instanceof ChannelRequestError ? "client" : "server";
20078
+ last = {
20079
+ kind,
20080
+ status: kind === "client" ? 400 : 502,
20081
+ message: `channel ${channelTarget.channel}: ${message}`
20082
+ };
20083
+ noteSwitch(member.modelId, kind, last.status);
20084
+ continue;
20085
+ }
20086
+ res.writeHead(200, {
20087
+ "Content-Type": "text/event-stream",
20088
+ "Cache-Control": "no-cache",
20089
+ "Connection": "keep-alive",
20090
+ "X-Accel-Buffering": "no"
20091
+ });
20092
+ for (const payload of hostFrames) res.write(`data: ${payload}\n\n`);
20093
+ res.write("data: [DONE]\n\n");
20094
+ res.end();
20095
+ return;
20096
+ }
20097
+ if (!member.aggregate) {
20098
+ const { account, reserveExempt } = await acquireForRequest(member.modelId, conversationKey, memberSignal);
20099
+ if (account === void 0) {
20100
+ const why = pool.unavailableReason(member.modelId, region, reserveExempt);
20101
+ last = {
20102
+ kind: why.reason,
20103
+ status: REASON_STATUS[why.reason] ?? 503,
20104
+ message: `member ${member.modelId} has no usable WorkBuddy account (${why.reason})`
20105
+ };
20106
+ noteSwitch(member.modelId, why.reason, last.status);
20107
+ continue;
20108
+ }
20109
+ const memberRaw = JSON.stringify({
20110
+ ...JSON.parse(raw),
20111
+ model: member.modelId
20112
+ });
20113
+ const prepared = client.prepareChatBodyFor(memberRaw, region ?? "cn");
20114
+ const result = await client.chatStream(account.credential, prepared, memberSignal, conversationKey);
20115
+ if (result.ok) {
20116
+ const turnId = randomBytes(8).toString("hex");
20117
+ await serveSuccessfulStream(res, account, result, logger, refreshBalance, pool, usageRecorder === void 0 ? void 0 : {
20118
+ recorder: usageRecorder,
20119
+ region: region ?? "cn",
20120
+ model: member.modelId,
20121
+ turnId
20122
+ });
20123
+ return;
20124
+ }
19054
20125
  last = {
19055
- kind: why.reason,
19056
- status: why.reason === "cooling" ? 429 : 401,
19057
- message: `member ${member.modelId} has no usable WorkBuddy account (${why.reason})`
20126
+ kind: result.kind,
20127
+ status: result.status,
20128
+ message: result.message
19058
20129
  };
20130
+ if (result.kind === "soft_rate") pool.penalize(account.id, parseRateLimitReset(result.message), member.modelId);
20131
+ else if (result.kind === "session_dead") pool.penalizeCredentialDead(account.id);
20132
+ else if (result.kind === "hard_credit") pool.penalizeExhausted(account.id);
20133
+ noteSwitch(member.modelId, result.kind, result.status);
19059
20134
  continue;
19060
20135
  }
19061
- const memberRaw = JSON.stringify({
19062
- ...JSON.parse(raw),
19063
- model: member.modelId
19064
- });
19065
- const prepared = client.prepareChatBodyFor(memberRaw, region ?? "cn");
19066
- const result = await client.chatStream(account.credential, prepared, signal, conversationKey);
19067
- if (result.ok) {
19068
- const turnId = randomBytes(8).toString("hex");
19069
- await serveSuccessfulStream(res, account, result, logger, refreshBalance, pool, usageRecorder === void 0 ? void 0 : {
19070
- recorder: usageRecorder,
19071
- region: region ?? "cn",
19072
- model: member.modelId,
19073
- turnId
20136
+ if (store === void 0 || aggregate === void 0) {
20137
+ last = {
20138
+ kind: "combo_config",
20139
+ status: 503,
20140
+ message: "the aggregate lane is not available on this profile"
20141
+ };
20142
+ noteSwitch(member.modelId, "combo_config", 503);
20143
+ continue;
20144
+ }
20145
+ const parsed = parseProviderModelId(member.modelId);
20146
+ if (parsed === null) {
20147
+ last = {
20148
+ kind: "combo_config",
20149
+ status: 400,
20150
+ message: `member ${member.modelId} is not a valid provider model id`
20151
+ };
20152
+ noteSwitch(member.modelId, "combo_config", 400);
20153
+ continue;
20154
+ }
20155
+ let resolved;
20156
+ try {
20157
+ resolved = resolveAggregateProvider(store, parsed.providerId);
20158
+ } catch (error) {
20159
+ last = {
20160
+ kind: "combo_config",
20161
+ status: 400,
20162
+ message: error instanceof Error ? error.message : String(error)
20163
+ };
20164
+ noteSwitch(member.modelId, "combo_config", 400);
20165
+ continue;
20166
+ }
20167
+ const frames = [];
20168
+ try {
20169
+ await forwardAggregateStreamed(resolved, parsed.modelId, withBareModelId(raw, parsed.modelId), {
20170
+ signal: memberSignal,
20171
+ onData: (payload) => frames.push(payload),
20172
+ ...usageRecorder === void 0 ? {} : { usage: {
20173
+ recorder: usageRecorder,
20174
+ turnId: conversationKey ?? randomBytes(8).toString("hex")
20175
+ } }
19074
20176
  });
19075
- return;
20177
+ } catch (error) {
20178
+ const kind = error instanceof AggregateError ? error.kind : "server";
20179
+ last = {
20180
+ kind,
20181
+ status: error instanceof AggregateError ? KIND_STATUS[error.kind] : 502,
20182
+ message: error instanceof Error ? error.message : String(error)
20183
+ };
20184
+ noteSwitch(member.modelId, kind, last.status);
20185
+ continue;
19076
20186
  }
19077
- last = {
19078
- kind: result.kind,
19079
- status: result.status,
19080
- message: result.message
19081
- };
19082
- if (result.kind === "soft_rate") pool.penalize(account.id, parseRateLimitReset(result.message), member.modelId);
19083
- else if (result.kind === "session_dead") pool.penalizeCredentialDead(account.id);
19084
- else if (result.kind === "hard_credit") pool.penalizeExhausted(account.id);
19085
- continue;
19086
- }
19087
- if (store === void 0 || aggregate === void 0) {
19088
- last = {
19089
- kind: "combo_config",
19090
- status: 503,
19091
- message: "the aggregate lane is not available on this profile"
19092
- };
19093
- continue;
19094
- }
19095
- const parsed = parseProviderModelId(member.modelId);
19096
- if (parsed === null) {
19097
- last = {
19098
- kind: "combo_config",
19099
- status: 400,
19100
- message: `member ${member.modelId} is not a valid provider model id`
19101
- };
19102
- continue;
19103
- }
19104
- let resolved;
19105
- try {
19106
- resolved = resolveAggregateProvider(store, parsed.providerId);
19107
- } catch (error) {
19108
- last = {
19109
- kind: "combo_config",
19110
- status: 400,
19111
- message: error instanceof Error ? error.message : String(error)
19112
- };
19113
- continue;
19114
- }
19115
- const frames = [];
19116
- try {
19117
- await forwardAggregateStreamed(resolved, parsed.modelId, withBareModelId(raw, parsed.modelId), {
19118
- signal,
19119
- onData: (payload) => frames.push(payload)
20187
+ if (switches.length > 0) logger?.info?.(`dsh-xdpool: chain ${row.id} served by ${member.modelId} after skipping ${String(switches.length)}/${String(members.length)}: ${switches.join("; ")}`);
20188
+ res.writeHead(200, {
20189
+ "Content-Type": "text/event-stream",
20190
+ "Cache-Control": "no-cache",
20191
+ "Connection": "keep-alive",
20192
+ "X-Accel-Buffering": "no"
19120
20193
  });
19121
- } catch (error) {
19122
- last = {
19123
- kind: error instanceof AggregateError ? error.kind : "server",
19124
- status: error instanceof AggregateError ? KIND_STATUS[error.kind] : 502,
19125
- message: error instanceof Error ? error.message : String(error)
19126
- };
19127
- continue;
20194
+ for (const payload of frames) res.write(`data: ${payload}\n\n`);
20195
+ res.write("data: [DONE]\n\n");
20196
+ res.end();
20197
+ return;
20198
+ } finally {
20199
+ releaseMember();
19128
20200
  }
19129
- res.writeHead(200, {
19130
- "Content-Type": "text/event-stream",
19131
- "Cache-Control": "no-cache",
19132
- "Connection": "keep-alive",
19133
- "X-Accel-Buffering": "no"
19134
- });
19135
- for (const payload of frames) res.write(`data: ${payload}\n\n`);
19136
- res.write("data: [DONE]\n\n");
19137
- res.end();
19138
- return;
19139
20201
  }
19140
20202
  const kind = last?.kind ?? "combo_empty";
19141
- writeOpenAIError(res, last?.status ?? 503, `combo_${kind}`, `chain ${row.id}: no member answered after ${attempts.length} [${attempts.join(" → ")}]: ${(last?.message ?? "no member was reachable").slice(0, 300)}`);
20203
+ const status = last?.status ?? 503;
20204
+ const trace = switches.length === 0 ? "" : ` [${switches.join("; ")}]`;
20205
+ writeOpenAIError(res, status, `combo_${kind}`, `chain ${row.id}: no member answered after ${attempts.length} [${attempts.join(" → ")}]: ${(last?.message ?? "no member was reachable").slice(0, 300)}${trace}`);
19142
20206
  }
19143
20207
  /**
19144
20208
  * Serve one chat completion, rotating accounts on rate limits.
@@ -19217,6 +20281,7 @@ function createWorkBuddyShim(options) {
19217
20281
  const conversationKey = conversationKeyOf(poolRaw);
19218
20282
  const tried = [];
19219
20283
  let last;
20284
+ const refreshedThisRequest = /* @__PURE__ */ new Set();
19220
20285
  const turnId = randomBytes(8).toString("hex");
19221
20286
  const usageContext = usageRecorder === void 0 ? void 0 : {
19222
20287
  recorder: usageRecorder,
@@ -19258,6 +20323,7 @@ function createWorkBuddyShim(options) {
19258
20323
  return;
19259
20324
  }
19260
20325
  usageRecorder?.record({
20326
+ channel: "workbuddy",
19261
20327
  region: region ?? "cn",
19262
20328
  model: modelId ?? "(none)",
19263
20329
  account: account.label,
@@ -19271,6 +20337,12 @@ function createWorkBuddyShim(options) {
19271
20337
  message: result.message
19272
20338
  };
19273
20339
  if (result.kind === "session_dead") {
20340
+ if (refreshedThisRequest.has(account.id)) {
20341
+ pool.penalizeCredentialDead(account.id);
20342
+ logger?.warn(`dsh-xdpool: ${account.label} still refused after a token refresh (attempt ${attempt + 1}/${maxAttempts}); rotating`);
20343
+ continue;
20344
+ }
20345
+ refreshedThisRequest.add(account.id);
19274
20346
  logger?.warn(`dsh-xdpool: ${account.label} session dead; refreshing token and retrying`);
19275
20347
  const before = account.credential.accessToken;
19276
20348
  if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
@@ -19304,8 +20376,11 @@ function createWorkBuddyShim(options) {
19304
20376
  pool,
19305
20377
  maxAttempts,
19306
20378
  acquireAccount: async () => (await acquireForRequest(modelId, conversationKey, controller.signal)).account,
19307
- usage: usageContext,
19308
- contextWindow: modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow
20379
+ ...usageContext === void 0 ? {} : { usage: usageContext },
20380
+ ...(() => {
20381
+ const window = modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow;
20382
+ return window === void 0 ? {} : { contextWindow: window };
20383
+ })()
19309
20384
  });
19310
20385
  if (recovered.ok) {
19311
20386
  await serveSuccessfulStream(res, recovered.account, recovered.result, logger, refreshBalance, pool, usageContext);
@@ -19412,6 +20487,7 @@ async function serveSuccessfulStream(res, account, result, logger, refreshBalanc
19412
20487
  recorded = true;
19413
20488
  if (usage?.recorder === void 0) return;
19414
20489
  usage.recorder.record({
20490
+ channel: "workbuddy",
19415
20491
  region: usage.region,
19416
20492
  model: usage.model,
19417
20493
  account: account.label,
@@ -19529,6 +20605,7 @@ async function recoverFromContextOverrun(options) {
19529
20605
  };
19530
20606
  const retryBody = client.buildChatBody(parsed, compacted, region ?? "cn");
19531
20607
  const tried = [];
20608
+ const refreshedThisRequest = /* @__PURE__ */ new Set();
19532
20609
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
19533
20610
  if (controller.signal.aborted) return {
19534
20611
  ok: false,
@@ -19542,6 +20619,7 @@ async function recoverFromContextOverrun(options) {
19542
20619
  tried.push(account.label);
19543
20620
  const result = await client.chatStream(account.credential, retryBody, controller.signal, conversationKeyOf(raw));
19544
20621
  if (!result.ok) usage?.recorder.record({
20622
+ channel: "workbuddy",
19545
20623
  region: usage.region,
19546
20624
  model: usage.model,
19547
20625
  account: account.label,
@@ -19562,6 +20640,11 @@ async function recoverFromContextOverrun(options) {
19562
20640
  detail: "prompt still exceeded the window after compaction"
19563
20641
  };
19564
20642
  if (result.kind === "session_dead") {
20643
+ if (refreshedThisRequest.has(account.id)) {
20644
+ pool.penalizeCredentialDead(account.id);
20645
+ continue;
20646
+ }
20647
+ refreshedThisRequest.add(account.id);
19565
20648
  const before = account.credential.accessToken;
19566
20649
  if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
19567
20650
  continue;
@@ -20331,7 +21414,7 @@ function readPreferredIdSync(path = preferredIdPath()) {
20331
21414
  * free of documents that describe the absence of a decision.
20332
21415
  */
20333
21416
  async function writePreferredId(accountId, path = preferredIdPath()) {
20334
- await mkdir(dirname(path), { recursive: true });
21417
+ await ensureSecretDir(dirname(path));
20335
21418
  if (accountId === void 0) {
20336
21419
  await rm(path, { force: true });
20337
21420
  return;
@@ -20341,7 +21424,7 @@ async function writePreferredId(accountId, path = preferredIdPath()) {
20341
21424
  accountId: accountId.trim()
20342
21425
  }, null, 2);
20343
21426
  const temp = `${path}.tmp`;
20344
- await writeFile(temp, `${payload}\n`, "utf8");
21427
+ await writeSecretFile(temp, `${payload}\n`);
20345
21428
  await rename(temp, path);
20346
21429
  }
20347
21430
  //#endregion
@@ -20400,40 +21483,59 @@ function relaySettingsOf(store) {
20400
21483
  * itself looks like it obeyed, and the user is the only one who can tell that a
20401
21484
  * member they paid for is now a fallback to something free.
20402
21485
  */
20403
- function combosStatusOf(store, catalog, poolRows) {
21486
+ function combosStatusOf(store, catalog, poolRows, hostRows = []) {
20404
21487
  const chains = combosOf(store);
20405
21488
  const providerRows = catalog.providerRows();
20406
21489
  const settings = new Map(configuredProviderIds(store).map((providerId) => [providerId, providerSettingOf(store, providerId)]));
20407
- const candidates = [...poolRows.map((row) => ({
20408
- id: row.id,
20409
- name: row.name,
20410
- owner: "workbuddy",
20411
- usable: true,
20412
- contextWindow: row.contextWindow,
20413
- supportsImages: row.supportsImages
20414
- })), ...providerRows.map((row) => {
20415
- const setting = settings.get(row.providerId);
20416
- const provider = getProvider(row.providerId);
20417
- return {
21490
+ const candidates = [
21491
+ ...poolRows.map((row) => ({
20418
21492
  id: row.id,
20419
21493
  name: row.name,
20420
- owner: row.providerId,
20421
- usable: provider === null || setting === void 0 ? false : providerAuthUsable(provider, setting),
21494
+ owner: "workbuddy",
21495
+ usable: true,
20422
21496
  contextWindow: row.contextWindow,
20423
- supportsImages: row.supportsImages
20424
- };
20425
- })];
21497
+ supportsImages: row.supportsImages,
21498
+ siblings: []
21499
+ })),
21500
+ ...providerRows.map((row) => {
21501
+ const setting = settings.get(row.providerId);
21502
+ const provider = getProvider(row.providerId);
21503
+ return {
21504
+ id: row.id,
21505
+ name: row.name,
21506
+ owner: row.providerId,
21507
+ usable: provider === null || setting === void 0 ? false : providerAuthUsable(provider, setting),
21508
+ contextWindow: row.contextWindow,
21509
+ supportsImages: row.supportsImages,
21510
+ siblings: siblingRoutes(row.id, {
21511
+ pool: poolRows,
21512
+ aggregate: providerRows
21513
+ }, row.providerId).filter((candidate) => candidate !== row.id)
21514
+ };
21515
+ }),
21516
+ ...hostRows.map((row) => ({
21517
+ id: row.id,
21518
+ name: row.name,
21519
+ owner: row.channel,
21520
+ usable: true,
21521
+ contextWindow: COMBO_DEFAULT_CONTEXT_WINDOW,
21522
+ supportsImages: false,
21523
+ siblings: []
21524
+ }))
21525
+ ];
20426
21526
  const known = new Set(candidates.map((candidate) => candidate.id));
20427
21527
  return {
20428
21528
  combos: chains.map((chain) => {
20429
21529
  const resolved = buildComboCatalog([chain], {
20430
21530
  pool: poolRows,
20431
- aggregate: providerRows
21531
+ aggregate: providerRows,
21532
+ host: hostRows
20432
21533
  })[0];
20433
21534
  return {
20434
21535
  id: resolved?.id ?? comboId(chain.name),
20435
21536
  name: chain.name,
20436
21537
  models: resolved?.models ?? [],
21538
+ platforms: splitPlatforms(resolved?.models ?? []),
20437
21539
  missing: chain.models.filter((modelId) => !known.has(modelId)),
20438
21540
  strategy: resolved?.strategy ?? chain.strategy ?? "fallback",
20439
21541
  stickyLimit: resolved?.stickyLimit ?? chain.stickyLimit ?? 1,
@@ -20621,7 +21723,19 @@ const Config = z.object({
20621
21723
  modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
20622
21724
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
20623
21725
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
20624
- automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
21726
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
21727
+ sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)")),
21728
+ /**
21729
+ * The 免密 free lane (`free:` models).
21730
+ *
21731
+ * OFF unless set, and it is the only lane that works that way. It is a shared
21732
+ * third-party endpoint with no credential of its own, so there is nothing for
21733
+ * a user to "configure" — it simply appears in the picker or does not. On by
21734
+ * default it added a group nobody asked for that the aggregate switch does not
21735
+ * govern (it is not an aggregate provider), leaving no way to remove it
21736
+ * without editing a file. Reported as "关不掉,应该默认是关闭的".
21737
+ */
21738
+ freeLane: asVolatile(z.boolean().default(false).description("Offer the shared 免密 free models (the \"XD Pool · 免费模型\" group). Off by default: it is a third-party endpoint with no key of your own."))
20625
21739
  });
20626
21740
  /** Live API, published for the CLI. */
20627
21741
  let api;
@@ -20678,6 +21792,16 @@ function warnOnPiAiGenerationMismatch(ctx) {
20678
21792
  } catch {}
20679
21793
  }
20680
21794
  /**
21795
+ * Late-bound switch for the 免密 lane, set once the catalog exists.
21796
+ *
21797
+ * `applyConfigFromSource` is defined before the catalog is built and can be
21798
+ * called at any point after, so it cannot close over the object directly without
21799
+ * a declaration-order dance. A one-line indirection states the ordering instead
21800
+ * of leaving it to be inferred: before the catalog exists this is a no-op, and
21801
+ * the catalog applies the current value itself the moment it is built.
21802
+ */
21803
+ let freeLaneSwitch = () => {};
21804
+ /**
20681
21805
  * Start the loopback endpoint, register the `xdpool` provider, and
20682
21806
  * discover accounts. The provider registers only after `shim.ready` resolves,
20683
21807
  * because its models read the shim origin at construction time.
@@ -20780,7 +21904,8 @@ function apply(ctx, config = {}) {
20780
21904
  }
20781
21905
  };
20782
21906
  const applyConfigFromSource = () => {
20783
- const { authFile, cooldownMs, distribution, disabledAccountIds, creditReserves, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation } = current();
21907
+ const { authFile, cooldownMs, distribution, disabledAccountIds, creditReserves, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation, freeLane: freeLaneEnabled } = current();
21908
+ freeLaneSwitch(freeLaneEnabled === true);
20784
21909
  core.pool.applyConfig({
20785
21910
  ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
20786
21911
  ...cooldownMs === void 0 ? {} : { cooldownMs },
@@ -20799,6 +21924,7 @@ function apply(ctx, config = {}) {
20799
21924
  };
20800
21925
  core.catalogs.cn.applySelection(modelSelectionCn ?? fallbackSelection("cn"));
20801
21926
  core.catalogs.global.applySelection(modelSelectionGlobal ?? fallbackSelection("global"));
21927
+ applyLaneSelection(laneSelectionFrom(core.catalogs.cn.currentSelection().enabledModelIds, core.catalogs.global.currentSelection().enabledModelIds));
20802
21928
  core.scheduler.applyConfig(automationOptions(automation));
20803
21929
  };
20804
21930
  const settingsService = ctx.settings;
@@ -20884,6 +22010,10 @@ function apply(ctx, config = {}) {
20884
22010
  const aggregateCatalog = new AggregateCatalog(() => readAggregateSettingsSync(), () => core.catalogs.cn.current());
20885
22011
  aggregateCatalog.refresh();
20886
22012
  const freeCatalog = new FreeCatalog();
22013
+ freeLaneSwitch = (enabled) => {
22014
+ freeCatalog.setEnabled(enabled);
22015
+ };
22016
+ freeLaneSwitch(current().freeLane === true);
20887
22017
  const freeLane = createFreeLaneSource(freeCatalog, ctx.logger, usageRecorder);
20888
22018
  /**
20889
22019
  * The login channels, for the LAN gateway.
@@ -20914,6 +22044,33 @@ function apply(ctx, config = {}) {
20914
22044
  * object has no reason to carry a list it never consults.
20915
22045
  */
20916
22046
  const lanModelsOf = () => readAggregateSettingsSync().relay?.models;
22047
+ /**
22048
+ * The login channels' models, as the route editor needs them.
22049
+ *
22050
+ * `channelLane.rows()` already caches per channel (60s TTL) and de-duplicates
22051
+ * concurrent requests, so calling it per routing-document request is cheap — but
22052
+ * it is ASYNC, and the chain table is built synchronously. Hence this helper: the
22053
+ * callers that can await do, and a failed listing degrades to the previous answer
22054
+ * rather than emptying the platform list (a channel that cannot be listed right
22055
+ * now must not make its models disappear from every chain).
22056
+ *
22057
+ * The two pool-owned channels are excluded by `channelLane` itself, so the same
22058
+ * model cannot appear once as a pool row and again as a channel row.
22059
+ */
22060
+ let hostRosterCache = [];
22061
+ const hostRoster = async () => {
22062
+ try {
22063
+ hostRosterCache = (await channelLane.rows()).map((row) => ({
22064
+ id: row.id,
22065
+ channel: row.channel,
22066
+ modelId: row.modelId,
22067
+ name: row.name
22068
+ }));
22069
+ } catch (error) {
22070
+ ctx.logger?.warn?.("dsh-xdpool: could not list login-channel models:", error);
22071
+ }
22072
+ return hostRosterCache;
22073
+ };
20917
22074
  const shims = {
20918
22075
  cn: createWorkBuddyShim({
20919
22076
  pool: core.pool,
@@ -21041,6 +22198,9 @@ function apply(ctx, config = {}) {
21041
22198
  else await updateProviderSetting(patch.providerId, {
21042
22199
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
21043
22200
  ...patch.apiKey === void 0 ? {} : { apiKey: patch.apiKey },
22201
+ ...patch.apiKeys === void 0 ? {} : { apiKeys: patch.apiKeys },
22202
+ ...patch.appendApiKey === void 0 ? {} : { appendApiKey: patch.appendApiKey },
22203
+ ...patch.distribution === void 0 ? {} : { distribution: patch.distribution },
21044
22204
  ...patch.baseUrl === void 0 ? {} : { baseUrl: patch.baseUrl },
21045
22205
  ...patch.models === void 0 ? {} : { models: patch.models }
21046
22206
  });
@@ -21178,11 +22338,11 @@ function apply(ctx, config = {}) {
21178
22338
  }))
21179
22339
  };
21180
22340
  },
21181
- combosDocument: () => combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current()),
22341
+ combosDocument: async () => combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current(), await hostRoster()),
21182
22342
  saveCombos: async (raw) => {
21183
22343
  await saveCombos(raw);
21184
22344
  await aggregateCatalog.refresh();
21185
- return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current());
22345
+ return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current(), await hostRoster());
21186
22346
  },
21187
22347
  /**
21188
22348
  * Relay state plus the gateway's model picker.
@@ -21258,7 +22418,7 @@ function apply(ctx, config = {}) {
21258
22418
  }) };
21259
22419
  }).finally(() => {
21260
22420
  if (stopped) return;
21261
- adoptChannelPackAccounts(ctx, core.pool, ctx.logger);
22421
+ adoptChannelPackAccounts(scoped, core.pool, ctx.logger);
21262
22422
  });
21263
22423
  });
21264
22424
  Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {