dsh-xdpool 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.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";
@@ -15,6 +15,15 @@ import { env } from "node:process";
15
15
  import http, { createServer, request } from "node:http";
16
16
  import https, { request as request$1 } from "node:https";
17
17
  import { Readable, Transform } from "node:stream";
18
+ //#region src/credit-reserve.ts
19
+ /** Build a merge-safe reserve update. Zero overrides inherited/previous floors. */
20
+ function creditReservesWith(current, accountId, reserve) {
21
+ return {
22
+ ...current,
23
+ [accountId]: reserve
24
+ };
25
+ }
26
+ //#endregion
18
27
  //#region src/badges.ts
19
28
  /**
20
29
  * Promotion badges the upstream attaches to a model.
@@ -159,24 +168,53 @@ function isAbortError(error) {
159
168
  }
160
169
  /** Cap on the reassembled compaction reply, guarding against a runaway stream. */
161
170
  const COMPLETION_TEXT_LIMIT = 65536;
162
- /** 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
+ */
163
182
  const HARD_CREDIT_MARKERS = [
164
183
  "insufficient credit",
184
+ "insufficient credits",
165
185
  "no credit",
186
+ "no credits",
166
187
  "credit exhausted",
188
+ "credits exhausted",
189
+ "credit exhausted.",
167
190
  "out of credit",
191
+ "out of credits",
168
192
  "quota exceeded",
169
193
  "quota exhaust",
194
+ "quota exhausted",
170
195
  "payment required",
171
196
  "credit not enough",
197
+ "credits not enough",
172
198
  "not enough credit",
199
+ "not enough credits",
173
200
  "积分不足",
174
201
  "额度不足",
175
202
  "余额不足",
176
203
  "积分用完",
177
204
  "额度用尽",
178
- "没有积分"
205
+ "没有积分",
206
+ "额度耗尽",
207
+ "积分耗尽"
179
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"];
180
218
  /** Session-invalidation markers that mean "this credential is dead; use another".
181
219
  * Kept alongside the HTTP-status rule in `classifyUpstreamError`: the status is
182
220
  * enough for a direct 401/403, but some failures arrive wrapped in a 200
@@ -421,7 +459,10 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
421
459
  const BANNED_SYSTEM_PHRASES = Object.freeze([
422
460
  [/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"],
423
461
  [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"],
424
- [/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"]
425
466
  ]);
426
467
  /**
427
468
  * Rewrite fingerprinted system/assistant content, leaving everything else
@@ -731,6 +772,7 @@ function classifyUpstreamError(status, body) {
731
772
  if (status === 402) return "hard_credit";
732
773
  if (status === 401 || status === 403) return "session_dead";
733
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";
734
776
  for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
735
777
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
736
778
  if (status === 429) return "soft_rate";
@@ -1153,7 +1195,8 @@ function parseTask(value) {
1153
1195
  status: typeof raw["status"] === "string" ? raw["status"] : "",
1154
1196
  claimable: !claimed && target > 0 && current >= target,
1155
1197
  claimed,
1156
- locked: raw["locked"] === true
1198
+ locked: raw["locked"] === true,
1199
+ jumpUrl: typeof raw["jump_url"] === "string" ? raw["jump_url"] : ""
1157
1200
  };
1158
1201
  }
1159
1202
  var WorkBuddyUpstreamClient = class {
@@ -2255,6 +2298,92 @@ var WorkBuddyUpstreamClient = class {
2255
2298
  }
2256
2299
  };
2257
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
2258
2387
  //#region src/ignored.ts
2259
2388
  /**
2260
2389
  * The permanent ignore list: accounts the user has thrown out of the pool.
@@ -2371,9 +2500,9 @@ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
2371
2500
  version: 1,
2372
2501
  accounts
2373
2502
  }, null, 2);
2374
- await mkdir(dirname(path), { recursive: true });
2503
+ await ensureSecretDir(dirname(path));
2375
2504
  const temp = `${path}.tmp`;
2376
- await writeFile(temp, `${payload}\n`, "utf8");
2505
+ await writeSecretFile(temp, `${payload}\n`);
2377
2506
  await rename(temp, path);
2378
2507
  }
2379
2508
  /**
@@ -2499,9 +2628,9 @@ async function saveStoredAccounts(path, accounts) {
2499
2628
  version: 1,
2500
2629
  accounts: stored
2501
2630
  }, null, 2) + "\n";
2502
- await mkdir(join(path, ".."), { recursive: true });
2631
+ await ensureSecretDir(join(path, ".."));
2503
2632
  const tmp = `${path}.tmp`;
2504
- await writeFile(tmp, document, "utf8");
2633
+ await writeSecretFile(tmp, document);
2505
2634
  await rename(tmp, path);
2506
2635
  }
2507
2636
  //#endregion
@@ -3265,16 +3394,20 @@ function parseWorkBuddyAuth(text, sourcePath, decrypt) {
3265
3394
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs$1(auth["refreshExpiresAt"]) : void 0;
3266
3395
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
3267
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"]);
3268
3401
  return {
3269
3402
  accessToken,
3270
3403
  refreshToken: decryptableString(auth["refreshToken"], decrypt).value,
3271
3404
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs$1(auth["expiresAt"]) : 0,
3272
3405
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
3273
3406
  ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
3274
- ...optionalString(decryptableString(identity["nickname"], decrypt).value) === void 0 ? {} : { nickname: optionalString(decryptableString(identity["nickname"], decrypt).value) },
3275
- ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
3276
- ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
3277
- ...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 },
3278
3411
  domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
3279
3412
  sourcePath
3280
3413
  };
@@ -3619,6 +3752,16 @@ var WorkBuddyAccountPool = class {
3619
3752
  */
3620
3753
  disabledIds = /* @__PURE__ */ new Set();
3621
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
+ /**
3622
3765
  * Account ids the user threw out of the pool for good.
3623
3766
  *
3624
3767
  * Enforced BEFORE the credential is parsed: `scan()` skips a file whose
@@ -3695,6 +3838,11 @@ var WorkBuddyAccountPool = class {
3695
3838
  * A function rather than a list because that pool is mounted asynchronously
3696
3839
  * and can be re-read on demand; `undefined` means "no such source", which is
3697
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.
3698
3846
  */
3699
3847
  externalSource;
3700
3848
  constructor(options = {}) {
@@ -3800,9 +3948,10 @@ var WorkBuddyAccountPool = class {
3800
3948
  }
3801
3949
  const byId = /* @__PURE__ */ new Map();
3802
3950
  for (const account of this.accounts) byId.set(account.id, account);
3803
- if (this.persistent && this.accounts.length === 0) {
3951
+ if (this.persistent) {
3804
3952
  const stored = await loadStoredAccounts(this.storePath);
3805
3953
  for (const entry of stored) {
3954
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(entry.id)) continue;
3806
3955
  if (byId.has(entry.id)) continue;
3807
3956
  byId.set(entry.id, {
3808
3957
  id: entry.id,
@@ -3852,7 +4001,10 @@ var WorkBuddyAccountPool = class {
3852
4001
  this.accounts = ordered;
3853
4002
  this.lastScanAtMs = Date.now();
3854
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.`);
3855
4006
  await saveStoredAccounts(this.storePath, this.accounts);
4007
+ this.lastPersistedIds = new Set(this.accounts.map((account) => account.id));
3856
4008
  } catch (error) {
3857
4009
  this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
3858
4010
  }
@@ -3872,11 +4024,11 @@ var WorkBuddyAccountPool = class {
3872
4024
  * model id the legacy account-wide check applies (callers that cannot name a
3873
4025
  * model, e.g. CLI diagnostics).
3874
4026
  */
3875
- available(now, modelId, region) {
4027
+ available(now, modelId, region, reserveExempt) {
3876
4028
  return this.accounts.filter((account) => {
3877
4029
  if (this.disabledIds.has(account.id)) return false;
3878
4030
  const reserve = this.creditReserves.get(account.id);
3879
- if (reserve !== void 0 && reserve > 0) {
4031
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3880
4032
  const balance = this.creditBalances.get(account.id);
3881
4033
  if (balance !== void 0 && balance <= reserve) return false;
3882
4034
  }
@@ -3900,7 +4052,7 @@ var WorkBuddyAccountPool = class {
3900
4052
  * Counts are over the region's accounts, since a provider only ever sees its
3901
4053
  * own gateway.
3902
4054
  */
3903
- unavailableReason(modelId, region) {
4055
+ unavailableReason(modelId, region, reserveExempt) {
3904
4056
  const now = Date.now();
3905
4057
  const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
3906
4058
  if (inRegion.length === 0) return {
@@ -3913,6 +4065,29 @@ var WorkBuddyAccountPool = class {
3913
4065
  let cooling = 0;
3914
4066
  let disabled = 0;
3915
4067
  let dead = 0;
4068
+ /**
4069
+ * Accounts that survived every OTHER filter and are held back only by their
4070
+ * credit floor.
4071
+ *
4072
+ * Counted rather than returned on the first hit. The old `return` fired the
4073
+ * moment it met one reserved account, so a single account parked under its
4074
+ * floor reported `reserve` for the whole gateway — even while four other
4075
+ * accounts were healthy and had just been tried. The caller turns
4076
+ * `reason === 'reserve'` into a 402 telling the user to lower the reserve,
4077
+ * which pointed at a setting that was not the problem (#98: a five-account
4078
+ * pool reported `reserve_floor` because of one account that had sat below
4079
+ * its floor for weeks, while the actual state was "the rest are cooling").
4080
+ */
4081
+ let reserved = 0;
4082
+ /**
4083
+ * Accounts that reached the end of the walk unblocked.
4084
+ *
4085
+ * Non-zero means the pool still has somewhere to send a request, so a
4086
+ * reserve is NOT what this answer is about: editing a number cannot fix a
4087
+ * request that some other account is already able to serve, and saying
4088
+ * otherwise sends the reader to the wrong setting.
4089
+ */
4090
+ let usable = 0;
3916
4091
  for (const account of inRegion) {
3917
4092
  if (this.disabledIds.has(account.id)) {
3918
4093
  disabled += 1;
@@ -3928,16 +4103,14 @@ var WorkBuddyAccountPool = class {
3928
4103
  continue;
3929
4104
  }
3930
4105
  const reserve = this.creditReserves.get(account.id);
3931
- if (reserve !== void 0 && reserve > 0) {
4106
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3932
4107
  const balance = this.creditBalances.get(account.id);
3933
- if (balance !== void 0 && balance <= reserve) return {
3934
- total: inRegion.length,
3935
- cooling,
3936
- disabled,
3937
- dead,
3938
- reason: "reserve"
3939
- };
4108
+ if (balance !== void 0 && balance <= reserve) {
4109
+ reserved += 1;
4110
+ continue;
4111
+ }
3940
4112
  }
4113
+ usable += 1;
3941
4114
  }
3942
4115
  if (dead > 0) return {
3943
4116
  total: inRegion.length,
@@ -3960,6 +4133,13 @@ var WorkBuddyAccountPool = class {
3960
4133
  dead,
3961
4134
  reason: "disabled"
3962
4135
  };
4136
+ if (usable === 0 && reserved > 0) return {
4137
+ total: inRegion.length,
4138
+ cooling,
4139
+ disabled,
4140
+ dead,
4141
+ reason: "reserve"
4142
+ };
3963
4143
  return {
3964
4144
  total: inRegion.length,
3965
4145
  cooling,
@@ -4136,16 +4316,18 @@ var WorkBuddyAccountPool = class {
4136
4316
  * Scans on first use, and rescans when every known account is cooling down: a
4137
4317
  * fresh desktop login is the usual way out of an exhausted pool.
4138
4318
  *
4319
+ * `reserveExempt` is host-only: account ids whose own credentials freshly
4320
+ * confirmed a zero-credit model. It never bypasses disabled, dead credentials, cooldowns or region.
4139
4321
  * `conversationKey` is only consulted under `sticky`; other modes ignore it.
4140
4322
  */
4141
- async acquire(modelId, region, conversationKey) {
4323
+ async acquire(modelId, region, conversationKey, reserveExempt) {
4142
4324
  if (this.accounts.length === 0) await this.scan();
4143
4325
  const maxTries = this.accounts.length + 1;
4144
4326
  for (let attempt = 0; attempt < maxTries; attempt += 1) {
4145
- let pool = this.available(Date.now(), modelId, region);
4327
+ let pool = this.available(Date.now(), modelId, region, reserveExempt);
4146
4328
  if (pool.length === 0 && attempt === 0) {
4147
4329
  await this.scan();
4148
- pool = this.available(Date.now(), modelId, region);
4330
+ pool = this.available(Date.now(), modelId, region, reserveExempt);
4149
4331
  }
4150
4332
  if (pool.length === 0) return void 0;
4151
4333
  if (this.preferredAccountId !== void 0) {
@@ -4338,6 +4520,43 @@ var WorkBuddyAccountPool = class {
4338
4520
  return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
4339
4521
  }
4340
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
+ /**
4341
4560
  * Refresh the account's access token when it is within the margin (or already
4342
4561
  * expired), in-flight de-duped per account. A failed refresh keeps the
4343
4562
  * existing token when it has not yet expired, so an unreachable refresh
@@ -4484,8 +4703,23 @@ ${new Date(until).toISOString()}
4484
4703
  * (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
4485
4704
  * authoritative while being wrong.
4486
4705
  *
4487
- * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
4488
- * 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.
4489
4723
  */
4490
4724
  const FALLBACK_WORKBUDDY_MODELS = [
4491
4725
  {
@@ -4502,8 +4736,7 @@ const FALLBACK_WORKBUDDY_MODELS = [
4502
4736
  contextWindow: 96e4,
4503
4737
  maxOutputTokens: 64e3,
4504
4738
  supportsImages: true,
4505
- multiplier: .29,
4506
- tags: ["craft", "badge:夜间免费:#FF0000"]
4739
+ tags: ["craft"]
4507
4740
  },
4508
4741
  {
4509
4742
  id: "hy3",
@@ -4511,40 +4744,35 @@ const FALLBACK_WORKBUDDY_MODELS = [
4511
4744
  contextWindow: 192e3,
4512
4745
  maxOutputTokens: 64e3,
4513
4746
  supportsImages: true,
4514
- multiplier: 0,
4515
- tags: ["craft", "badge:限时免费:#FF0000"]
4747
+ tags: ["craft"]
4516
4748
  },
4517
4749
  {
4518
4750
  id: "hy3-x",
4519
4751
  name: "Hy3",
4520
4752
  contextWindow: 192e3,
4521
4753
  maxOutputTokens: 64e3,
4522
- supportsImages: true,
4523
- multiplier: .05
4754
+ supportsImages: true
4524
4755
  },
4525
4756
  {
4526
4757
  id: "space-bunny",
4527
4758
  name: "Space-Bunny",
4528
4759
  contextWindow: 1e6,
4529
4760
  maxOutputTokens: 64e3,
4530
- supportsImages: true,
4531
- multiplier: .08
4761
+ supportsImages: true
4532
4762
  },
4533
4763
  {
4534
4764
  id: "deepseek-v4.1-flash",
4535
4765
  name: "Deepseek-V4.1-Flash",
4536
4766
  contextWindow: 1e6,
4537
4767
  maxOutputTokens: 128e3,
4538
- supportsImages: true,
4539
- multiplier: .11
4768
+ supportsImages: true
4540
4769
  },
4541
4770
  {
4542
4771
  id: "deepseek-v4-pro",
4543
4772
  name: "Deepseek-V4-Pro",
4544
4773
  contextWindow: 1e6,
4545
4774
  maxOutputTokens: 128e3,
4546
- supportsImages: true,
4547
- multiplier: .51
4775
+ supportsImages: true
4548
4776
  },
4549
4777
  {
4550
4778
  id: "deepseek-v4-flash",
@@ -4558,16 +4786,14 @@ const FALLBACK_WORKBUDDY_MODELS = [
4558
4786
  name: "GLM-5.3",
4559
4787
  contextWindow: 1e6,
4560
4788
  maxOutputTokens: 64e3,
4561
- supportsImages: true,
4562
- multiplier: .79
4789
+ supportsImages: true
4563
4790
  },
4564
4791
  {
4565
4792
  id: "glm-5.3-flash",
4566
4793
  name: "GLM-5.3-Flash",
4567
4794
  contextWindow: 1e6,
4568
4795
  maxOutputTokens: 131072,
4569
- supportsImages: true,
4570
- multiplier: .06
4796
+ supportsImages: true
4571
4797
  },
4572
4798
  {
4573
4799
  id: "glm-5.2",
@@ -4575,64 +4801,56 @@ const FALLBACK_WORKBUDDY_MODELS = [
4575
4801
  contextWindow: 1e6,
4576
4802
  maxOutputTokens: 64e3,
4577
4803
  supportsImages: true,
4578
- multiplier: .79,
4579
- tags: ["craft", "badge:夜间折扣:#1E90FF"]
4804
+ tags: ["craft"]
4580
4805
  },
4581
4806
  {
4582
4807
  id: "glm-5.1",
4583
4808
  name: "GLM-5.1",
4584
4809
  contextWindow: 2e5,
4585
4810
  maxOutputTokens: 48e3,
4586
- supportsImages: false,
4587
- multiplier: .79
4811
+ supportsImages: false
4588
4812
  },
4589
4813
  {
4590
4814
  id: "glm-5v-turbo",
4591
4815
  name: "GLM-5v-Turbo",
4592
4816
  contextWindow: 2e5,
4593
4817
  maxOutputTokens: 64e3,
4594
- supportsImages: true,
4595
- multiplier: .71
4818
+ supportsImages: true
4596
4819
  },
4597
4820
  {
4598
4821
  id: "kimi-k3-1",
4599
4822
  name: "Kimi-K3",
4600
4823
  contextWindow: 1e6,
4601
4824
  maxOutputTokens: 32e3,
4602
- supportsImages: true,
4603
- multiplier: 1.62
4825
+ supportsImages: true
4604
4826
  },
4605
4827
  {
4606
4828
  id: "kimi-k2.8-preview",
4607
4829
  name: "Kimi-K2.8-Preview",
4608
4830
  contextWindow: 1e6,
4609
4831
  maxOutputTokens: 64e3,
4610
- supportsImages: true,
4611
- multiplier: .77
4832
+ supportsImages: true
4612
4833
  },
4613
4834
  {
4614
4835
  id: "kimi-k2.7",
4615
4836
  name: "Kimi-K2.7-Code",
4616
4837
  contextWindow: 256e3,
4617
4838
  maxOutputTokens: 32e3,
4618
- supportsImages: true,
4619
- multiplier: .57
4839
+ supportsImages: true
4620
4840
  },
4621
4841
  {
4622
4842
  id: "kimi-k2.6",
4623
4843
  name: "Kimi-K2.6",
4624
4844
  contextWindow: 256e3,
4625
4845
  maxOutputTokens: 32e3,
4626
- supportsImages: true,
4627
- multiplier: .52
4846
+ supportsImages: true
4628
4847
  },
4629
4848
  {
4630
4849
  id: "minimax-m3",
4631
4850
  name: "MiniMax-M3",
4632
4851
  contextWindow: 512e3,
4633
4852
  maxOutputTokens: 64e3,
4634
- supportsImages: true,
4635
- multiplier: .25
4853
+ supportsImages: true
4636
4854
  }
4637
4855
  ];
4638
4856
  /**
@@ -4705,11 +4923,24 @@ var WorkBuddyCatalog = class {
4705
4923
  }
4706
4924
  /**
4707
4925
  * The models DSH should actually offer, after applying the user's selection:
4708
- * disabled models are dropped, an explicit image list overrides the upstream
4709
- * 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.
4710
4928
  *
4711
4929
  * An absent `enabledModelIds` means "everything" — a fresh install with no
4712
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".
4713
4944
  */
4714
4945
  visible() {
4715
4946
  const enabled = this.selection.enabledModelIds;
@@ -4719,7 +4950,7 @@ var WorkBuddyCatalog = class {
4719
4950
  const budgets = this.selection.contextBudgets;
4720
4951
  return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
4721
4952
  const next = { ...model };
4722
- if (imageSet !== void 0) next.supportsImages = next.supportsImages || imageSet.has(model.id);
4953
+ if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
4723
4954
  const budget = budgets?.[model.id];
4724
4955
  if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
4725
4956
  return next;
@@ -4998,22 +5229,6 @@ const POOL_COMBOS_SAVE_PATH = "/plugins/dsh-xdpool/combos/save";
4998
5229
  */
4999
5230
  const POOL_RELAY_PATH = "/plugins/dsh-xdpool/relay";
5000
5231
  /**
5001
- * What the locally running billion-context proxy is doing, read-only.
5002
- *
5003
- * Its own route because the answer comes from ANOTHER plugin: billion-context
5004
- * runs its own loopback listener and publishes its own JSON, so this route only
5005
- * finds that listener and relays a filtered subset. Nothing here is folded into
5006
- * {@link POOL_STATUS_PATH}: the pool card polls that every 30 seconds, and a
5007
- * third-party proxy's session table is not something every pool poll should
5008
- * carry.
5009
- *
5010
- * The word "read-only" is load-bearing on both ends. This plugin sends GETs
5011
- * only — it never configures, restarts or writes to the proxy — and the payload
5012
- * it relays deliberately omits every field that could carry conversation
5013
- * material.
5014
- */
5015
- const POOL_BILLION_PATH = "/plugins/dsh-xdpool/billion";
5016
- /**
5017
5232
  * Read the usage panel's aggregate.
5018
5233
  *
5019
5234
  * GET only, and token-free by construction: the recorder holds counts and
@@ -5023,6 +5238,18 @@ const POOL_BILLION_PATH = "/plugins/dsh-xdpool/billion";
5023
5238
  * instead of a second one that could quietly drift.
5024
5239
  */
5025
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
+ }
5026
5253
  /**
5027
5254
  * The schedule every automation job falls back to.
5028
5255
  *
@@ -10279,6 +10506,24 @@ const PROVIDERS = Object.freeze([
10279
10506
  "website": "https://openai.com/",
10280
10507
  "local": false,
10281
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
10282
10527
  }
10283
10528
  ].map((entry) => Object.freeze({
10284
10529
  ...entry,
@@ -10391,6 +10636,71 @@ const COMBO_PREFIX = "combo:";
10391
10636
  const COMBO_CHANNEL = "combo";
10392
10637
  /** Chain strategies. Anything else on disk falls back to `fallback`. */
10393
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;
10394
10704
  /** A trimmed non-empty string, or ''. */
10395
10705
  function text$6(value) {
10396
10706
  return typeof value === "string" ? value.trim() : "";
@@ -10460,7 +10770,8 @@ function normalizeCombos(raw) {
10460
10770
  const name = text$6(value.name) || text$6(value.id);
10461
10771
  if (name === "") continue;
10462
10772
  const id = text$6(value.id);
10463
- 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);
10464
10775
  if (models.length === 0) continue;
10465
10776
  const chainId = isComboId(id) ? id : comboId(name);
10466
10777
  if (chainId === "" || seen.has(chainId)) continue;
@@ -10471,6 +10782,7 @@ function normalizeCombos(raw) {
10471
10782
  id: chainId,
10472
10783
  name,
10473
10784
  models,
10785
+ ...platforms === void 0 ? {} : { platforms },
10474
10786
  strategy,
10475
10787
  stickyLimit: limit > 0 ? limit : 1,
10476
10788
  enabled: value.enabled !== false
@@ -10479,6 +10791,30 @@ function normalizeCombos(raw) {
10479
10791
  return out;
10480
10792
  }
10481
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
+ /**
10482
10818
  * Classify one member id against both catalogs.
10483
10819
  *
10484
10820
  * `combo:` ids are rejected outright: a chain pointing at another chain would
@@ -10501,6 +10837,31 @@ function resolveComboMember(modelId, sources) {
10501
10837
  supportsImages: aggregateRow.supportsImages
10502
10838
  };
10503
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
+ };
10504
10865
  const poolRow = sources.pool.find((row) => row.id === id);
10505
10866
  if (poolRow === void 0) return void 0;
10506
10867
  return {
@@ -10513,6 +10874,54 @@ function resolveComboMember(modelId, sources) {
10513
10874
  };
10514
10875
  }
10515
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
+ /**
10516
10925
  * Every combo row that can currently answer, given the catalogs.
10517
10926
  *
10518
10927
  * Disabled chains are skipped, as are chains whose members have all gone away:
@@ -10636,6 +11045,13 @@ function normalizeSetting(raw) {
10636
11045
  if (typeof value.enabled === "boolean") setting.enabled = value.enabled;
10637
11046
  const key = text$5(value.apiKey);
10638
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();
10639
11055
  const baseUrl = text$5(value.baseUrl);
10640
11056
  if (baseUrl !== "") setting.baseUrl = baseUrl.replace(/\/+$/, "");
10641
11057
  if (Array.isArray(value.models)) {
@@ -10756,12 +11172,12 @@ function normalizeRelayModels(raw) {
10756
11172
  async function writeAggregateSettings(settings) {
10757
11173
  const path = aggregateSettingsPath();
10758
11174
  if (path === void 0) throw new Error("no home directory to store the aggregate settings in");
10759
- await mkdir(dirname(path), { recursive: true });
11175
+ await ensureSecretDir(dirname(path));
10760
11176
  const file = {
10761
11177
  version: STORE_VERSION,
10762
11178
  ...settings
10763
11179
  };
10764
- await writeFile(`${path}.tmp`, `${JSON.stringify(file, null, 2)}\n`, "utf8");
11180
+ await writeSecretFile(`${path}.tmp`, `${JSON.stringify(file, null, 2)}\n`);
10765
11181
  await rename(`${path}.tmp`, path);
10766
11182
  }
10767
11183
  /**
@@ -10783,16 +11199,42 @@ async function updateProviderSetting(providerId, patch) {
10783
11199
  if (baseUrl === "") delete after.baseUrl;
10784
11200
  else after.baseUrl = baseUrl;
10785
11201
  }
10786
- if (patch.apiKey !== void 0) {
10787
- const key = text$5(patch.apiKey);
10788
- if (key === "") delete after.apiKey;
10789
- 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
+ }
10790
11227
  }
10791
11228
  if (patch.models !== void 0) {
10792
11229
  const models = stringList$1(patch.models);
10793
11230
  if (models.length === 0) delete after.models;
10794
11231
  else after.models = models;
10795
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
+ }
10796
11238
  if (patch.discovered !== void 0) {
10797
11239
  const discovered = stringList$1(patch.discovered);
10798
11240
  if (discovered.length === 0) delete after.discovered;
@@ -10891,6 +11333,62 @@ function providerSettingOf(store, providerId) {
10891
11333
  return store.providers?.[providerId] ?? {};
10892
11334
  }
10893
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
+ /**
10894
11392
  * Whether a provider could serve a model right now.
10895
11393
  *
10896
11394
  * Keyless and local providers are usable with nothing configured; `oauth` and
@@ -10900,7 +11398,7 @@ function providerSettingOf(store, providerId) {
10900
11398
  */
10901
11399
  function providerAuthUsable(provider, setting) {
10902
11400
  if (provider.auth.kind === "oauth" || provider.auth.kind === "adc") return false;
10903
- if (provider.auth.kind === "apiKey") return text$5(setting.apiKey) !== "";
11401
+ if (provider.auth.kind === "apiKey") return providerApiKeys(setting).length > 0;
10904
11402
  return true;
10905
11403
  }
10906
11404
  /**
@@ -10945,275 +11443,653 @@ function configuredProviderIds(store) {
10945
11443
  return out;
10946
11444
  }
10947
11445
  //#endregion
10948
- //#region src/aggregate.ts
11446
+ //#region src/stats.ts
10949
11447
  /**
10950
- * Aggregate lane: the 76 declared upstream providers, resolved per request and
10951
- * forwarded to with the user's own key.
10952
- *
10953
- * WHY A SECOND LANE BESIDE THE POOL
10954
- *
10955
- * The pool serves exactly one family of gateways — WorkBuddy, CN and
10956
- * international — from accounts the user already signed in on the desktop app.
10957
- * This module serves the orthogonal case: a provider the user holds a key for,
10958
- * reached at that provider's own endpoint, with no account rotation because
10959
- * there is no account to rotate. Same card, same model picker, different wire.
10960
- *
10961
- * WHAT IT DELIBERATELY DOES NOT DO
10962
- *
10963
- * It does not invent endpoints. Every URL here comes from the registry record or
10964
- * from a base-URL override the user typed; a provider whose endpoint cannot be
10965
- * composed is reported as such rather than being probed at a guessed URL, because
10966
- * "no listing" and "provider is down" must not read the same to the user.
10967
- *
10968
- * It also does not decide whether a provider is USABLE — that is the settings
10969
- * layer's call (`providerAuthUsable`). This module resolves what a request
10970
- * needs, and refuses with a message naming what is missing.
10971
- *
10972
- * KEYS
10973
- *
10974
- * Every string that can reach a message — a failure, a body snippet, a probe's
10975
- * answer — passes through {@link redactAggregateSecret} first. Providers echo
10976
- * rejected credentials back (a 401 body routinely repeats the key), and this
10977
- * text is folded into messages the host writes to its durable session log.
10978
- *
10979
- * @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.
10980
11450
  */
10981
- const WIRE_BY_FORMAT = Object.freeze({
10982
- "openai-chat": "chat",
10983
- "openai-responses": "responses",
10984
- "anthropic-messages": "messages"
10985
- });
11451
+ const USAGE_MAX_SAMPLES = 2e3;
10986
11452
  /**
10987
- * Wire suffixes, needed only for the one record the registry gives no base URL
10988
- * to (Azure names its endpoint per deployment, so `chatEndpoint` is null there
10989
- * 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.
10990
11458
  */
10991
- const CHAT_SUFFIX = Object.freeze({
10992
- "openai-chat": "/chat/completions",
10993
- "openai-responses": "/responses",
10994
- "anthropic-messages": "/messages"
10995
- });
11459
+ const MIN_DECODE_MS = 120;
10996
11460
  /**
10997
- * 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.
10998
11463
  *
10999
- * Deliberately conservative, and the reason is visible in the number: most
11000
- * registry rows publish no context window at all. Publishing a plausible one
11001
- * would put a fabricated field in front of the user — and unlike a display
11002
- * label, a wrong window is a wrong capability, so DSH would hand the provider a
11003
- * 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.
11004
11467
  */
11005
- const DEFAULT_CONTEXT_WINDOW = 131072;
11006
- const DEFAULT_MAX_OUTPUT = 32768;
11007
- /** Listing round budget. Generous for a small JSON document, short for a UI. */
11008
- const LISTING_TIMEOUT_MS = 15e3;
11009
- /** One turn's budget, matching the pool shim's idle ceiling. */
11010
- const TURN_TIMEOUT_MS = 3e5;
11011
- /** Sent when the registry does not declare one (kimchi, and most of the roster). */
11012
- const USER_AGENT = "dsh-xdpool/2.1";
11013
- /** How much of an upstream body is quoted back in a failure message. */
11014
- const BODY_SNIPPET_CHARS = 400;
11015
- /** A trimmed non-empty string, or ''. */
11016
- function text$4(value) {
11017
- return typeof value === "string" && value.trim() !== "" ? value.trim() : "";
11018
- }
11019
- /** A positive finite number, or undefined. */
11020
- function number(value) {
11021
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : void 0;
11022
- }
11023
- function isRecord$3(value) {
11024
- return value !== null && typeof value === "object" && !Array.isArray(value);
11025
- }
11026
- /**
11027
- * One aggregate-lane failure, carrying the class the caller maps onto HTTP.
11028
- *
11029
- * `kind` reuses the pool's vocabulary (`UpstreamErrorKind` in `upstream.ts`) so
11030
- * the shim's existing error→status table serves both lanes: a third-party 429
11031
- * is a `soft_rate`, a 401 is a `session_dead`, and neither needs a second table
11032
- * that could drift from the first.
11033
- */
11034
- var AggregateError = class extends Error {
11035
- kind;
11036
- status;
11037
- constructor(kind, message, status = 0) {
11038
- super(message);
11039
- this.name = "AggregateError";
11040
- this.kind = kind;
11041
- this.status = status;
11042
- }
11043
- };
11044
- /** Remove a credential from anything that may reach a message. */
11045
- function redactAggregateSecret(value, apiKey) {
11046
- let source = typeof value === "string" ? value : String(value ?? "");
11047
- const key = text$4(apiKey);
11048
- if (key === "") return source;
11049
- const escaped = JSON.stringify(key).slice(1, -1);
11050
- for (const needle of escaped === key ? [key] : [key, escaped]) if (source.includes(needle)) source = source.split(needle).join("[redacted]");
11051
- return source;
11052
- }
11053
- /** A base URL a request can actually be sent to. */
11054
- function hasPlaceholder(baseUrl) {
11055
- return /\{[^}]*\}/.test(baseUrl);
11056
- }
11057
- /** The user's `baseUrl` override when present, else the registry's own. */
11058
- function effectiveBaseUrl(provider, setting) {
11059
- const override = text$4(setting.baseUrl);
11060
- return (override !== "" ? override : text$4(provider.baseUrl)).replace(/\/+$/, "");
11061
- }
11062
- /**
11063
- * The provider's listing URL, moved by a `baseUrl` override when — and only
11064
- * when — the declared listing actually lives under the registry base.
11065
- *
11066
- * Three providers (cohere, deepinfra, ollama_cloud) publish their listing on
11067
- * another path. Rewriting a URL that never pointed at the override would invent
11068
- * one, and a guessed listing is worse than none.
11069
- */
11070
- function effectiveModelsUrl(provider, setting) {
11071
- const declared = modelsEndpoint(provider);
11072
- if (declared === null) return null;
11073
- const override = text$4(setting.baseUrl).replace(/\/+$/, "");
11074
- const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
11075
- if (override === "" || registryBase === "" || !declared.startsWith(registryBase)) return declared;
11076
- return `${override}${declared.slice(registryBase.length)}`;
11468
+ function count(value) {
11469
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
11077
11470
  }
11078
- /**
11079
- * The full URL a turn is posted to; null when the provider has no dialable
11080
- * endpoint (Azure without a user base URL, or a base still carrying the
11081
- * registry's `{account_id}`-style placeholder).
11082
- */
11083
- function effectiveChatUrl(provider, setting) {
11084
- const override = text$4(setting.baseUrl).replace(/\/+$/, "");
11085
- const declared = chatEndpoint(provider);
11086
- if (declared !== null) {
11087
- const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
11088
- const url = override === "" || registryBase === "" ? declared : `${override}${declared.slice(registryBase.length)}`;
11089
- return hasPlaceholder(url) ? null : url;
11090
- }
11091
- const suffix = CHAT_SUFFIX[provider.format];
11092
- if (override === "" || suffix === void 0 || hasPlaceholder(override)) return null;
11093
- return `${override}${suffix}`;
11471
+ function zero(value) {
11472
+ return count(value) ?? 0;
11094
11473
  }
11095
- /** The per-provider settings object, or `{}` when the store names none. */
11096
- function settingFor(store, providerId) {
11097
- return store.providers?.[providerId] ?? {};
11474
+ function detailOf(value, key) {
11475
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return 0;
11476
+ return zero(value[key]);
11098
11477
  }
11099
11478
  /**
11100
- * Resolve one provider's effective settings.
11101
- *
11102
- * Throws {@link AggregateError} with a `client` kind for every state the user
11103
- * fixes in the settings page — an unknown id, a missing key, a placeholder base
11104
- * URL. A `client` kind is deliberate: none of these is a transport fault, so
11105
- * none of them should be retried against a different provider.
11479
+ * Read a gateway `usage` object, accepting both spellings OpenAI-compatible
11480
+ * gateways use (`prompt_tokens` / `input_tokens`).
11106
11481
  *
11107
- * `options.requireEnabled` exists for the DISCOVERY calls. Fetching a provider's
11108
- * model list publishes nothing: it asks "does this endpoint answer, and with
11109
- * what", and writing the answer back is a separate step the user also takes.
11110
- * Gating it on the enable switch made a read-only button report
11111
- * `"OpenCode Free" is not enabled in the aggregate settings` — a failure to
11112
- * LOOK, dressed up as a failure to connect, on rows that carry no key field and
11113
- * so offered no way to answer it. Discovery therefore does not require the
11114
- * switch; publishing still does, in {@link buildAggregateCatalog} and
11115
- * {@link resolveAggregateProvider} as called by the serving paths.
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".
11116
11484
  */
11117
- function resolveAggregateProvider(store, providerId, options = {}) {
11118
- const id = text$4(providerId);
11119
- const provider = id === "" ? null : getProvider(id);
11120
- if (provider === null) throw new AggregateError("client", `the aggregate lane has no provider "${id === "" ? String(providerId) : id}"`);
11121
- const setting = settingFor(store, provider.id);
11122
- if (setting.enabled === false) throw new AggregateError("client", `"${provider.name}" is switched off in the aggregate settings`);
11123
- if (!providerAuthUsable(provider, setting)) {
11124
- 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`);
11125
- const where = text$4(provider.auth.url) === "" ? "" : ` (the provider issues keys at ${provider.auth.url})`;
11126
- throw new AggregateError("client", `"${provider.name}" needs an API key: paste one in the aggregate settings${where}`);
11127
- }
11128
- if (options.requireEnabled !== false && !providerEnabled(provider, setting)) throw new AggregateError("client", `"${provider.name}" is not enabled in the aggregate settings`);
11129
- const baseUrl = effectiveBaseUrl(provider, setting);
11130
- if (baseUrl === "") throw new AggregateError("client", `"${provider.name}" has no base URL of its own — set one in the aggregate settings`);
11131
- 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`);
11132
- const chatUrl = effectiveChatUrl(provider, setting);
11133
- if (chatUrl === null) throw new AggregateError("client", `"${provider.name}" has no usable ${provider.format} endpoint`);
11134
- const apiKey = text$4(setting.apiKey);
11135
- const headers = defaultHeaders(provider, apiKey);
11136
- if (headers["user-agent"] === void 0) headers["user-agent"] = USER_AGENT;
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;
11137
11491
  return {
11138
- providerId: provider.id,
11139
- providerName: provider.name,
11140
- apiKey,
11141
- baseUrl,
11142
- format: provider.format,
11143
- wire: WIRE_BY_FORMAT[provider.format],
11144
- chatUrl,
11145
- modelsUrl: effectiveModelsUrl(provider, setting),
11146
- headers
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")
11147
11496
  };
11148
11497
  }
11149
11498
  /**
11150
- * The ids a provider should be served with, and where that list came from.
11499
+ * True for the trailer frame a gateway sends to report usage.
11151
11500
  *
11152
- * `discovered` is the roster a fetch returned, `models` is the subset the user
11153
- * kept, and the choice is applied LAST so it wins over both — otherwise pressing
11154
- * "Fetch models" would silently undo the narrowing the card just saved.
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.
11155
11505
  */
11156
- function modelIdsFor(provider, setting) {
11157
- const universe = modelChoicesFor(provider, setting);
11158
- const source = rosterIsDiscovered(provider, setting) ? "discovered" : "static";
11159
- const override = stringList(setting.models);
11160
- if (override.length === 0) return {
11161
- ids: universe,
11162
- source
11163
- };
11164
- const available = new Set(universe);
11165
- return {
11166
- ids: override.filter((id) => available.has(id)),
11167
- source
11168
- };
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;
11512
+ }
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}`;
11169
11519
  }
11170
11520
  /**
11171
- * Whether the roster came from a live fetch rather than the registry.
11521
+ * Timing and usage captured while one stream is forwarded.
11172
11522
  *
11173
- * A pre-split install has `models` and no `discovered`, because until the
11174
- * chooser existed the only writer of `models` WAS the fetch button. Those ids
11175
- * are a roster, not a choice, and treating them as one would shrink the vendor
11176
- * to whatever handful the registry happens to declare.
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.
11177
11525
  */
11178
- function rosterIsDiscovered(provider, setting) {
11179
- if ((setting.discovered?.length ?? 0) > 0) return true;
11180
- return stringList(setting.models).length > provider.models.length;
11181
- }
11182
- /** Every id a provider could offer: its roster, for the card's chooser. */
11183
- function modelChoicesFor(provider, setting) {
11184
- const discovered = stringList(setting.discovered);
11185
- if (discovered.length === 0 && rosterIsDiscovered(provider, setting)) return stringList(setting.models);
11186
- return discovered.length > 0 ? discovered : provider.models.map((model) => model.id);
11187
- }
11188
- /** A de-duplicated list of non-empty trimmed strings from an untrusted array. */
11189
- function stringList(value) {
11190
- if (!Array.isArray(value)) return [];
11191
- const seen = /* @__PURE__ */ new Set();
11192
- const ids = [];
11193
- for (const raw of value) {
11194
- const id = text$4(raw);
11195
- if (id === "" || seen.has(id)) continue;
11196
- seen.add(id);
11197
- ids.push(id);
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;
11198
11537
  }
11199
- return ids;
11200
- }
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
+ };
11201
11559
  /**
11202
- * One catalog row.
11560
+ * A bounded window of recent calls, summarised on demand.
11203
11561
  *
11204
- * Capacities come from the registry's model row where it has them, and fall back
11205
- * to this module's own defaults where it does not (most rows carry no context
11206
- * window, and none carries an output ceiling — the registry records ids and
11207
- * published capacities, not a serving contract).
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.
11208
11566
  */
11209
- function rowOf(provider, modelId, source) {
11210
- const declared = provider.models.find((model) => model.id === modelId);
11211
- const contextWindow = number(declared?.contextWindow) ?? DEFAULT_CONTEXT_WINDOW;
11212
- const label = text$4(declared?.name) !== "" ? text$4(declared?.name) : modelId;
11213
- return {
11214
- id: providerModelId(provider.id, modelId),
11215
- name: `${provider.name} ${label}`,
11216
- providerId: provider.id,
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,
11217
12093
  modelId,
11218
12094
  contextWindow,
11219
12095
  maxOutputTokens: Math.min(DEFAULT_MAX_OUTPUT, contextWindow),
@@ -11517,30 +12393,80 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
11517
12393
  stream: true
11518
12394
  });
11519
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;
11520
12420
  const timeoutMs = options.timeoutMs ?? TURN_TIMEOUT_MS;
11521
12421
  const onCallerAbort = () => controller.abort();
11522
12422
  options.signal?.addEventListener("abort", onCallerAbort, { once: true });
11523
12423
  try {
11524
- const response = await fetch(resolved.chatUrl, {
11525
- method: "POST",
11526
- headers: {
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 = {
11527
12445
  ...resolved.headers,
11528
12446
  accept: "text/event-stream"
11529
- },
11530
- body: bodyText,
11531
- redirect: "error",
11532
- signal: controller.signal
11533
- });
11534
- const retryAfter = retryAfterMs(response);
11535
- if (!response.ok) {
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);
11536
12460
  const refusal = await response.text().catch(() => "");
11537
- 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;
11538
12463
  }
11539
12464
  if (response.body === null) throw new AggregateError("server", "the provider returned no body", response.status);
11540
12465
  const head = await readSseHead(response.body, controller.signal, timeoutMs);
11541
12466
  if (head.shape === "empty") throw new AggregateError("server", "the provider returned no body", response.status);
11542
12467
  if (head.shape === "sse") {
11543
- for (const payload of head.payloads) options.onData(payload);
12468
+ for (const payload of head.payloads) emit(payload);
12469
+ delivered = true;
11544
12470
  return { status: response.status };
11545
12471
  }
11546
12472
  let raw = head.text;
@@ -11562,8 +12488,9 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
11562
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);
11563
12489
  throw new AggregateError("server", `unexpected non-stream response: ${redactAggregateSecret(raw, resolved.apiKey).slice(0, 200)}`, response.status);
11564
12490
  }
11565
- if (isRecord$3(payload) && (payload.error !== void 0 || payload.type === "error")) throw classifyUpstream(response.status, raw, retryAfter, resolved.apiKey);
11566
- 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;
11567
12494
  return { status: response.status };
11568
12495
  } catch (error) {
11569
12496
  if (error instanceof AggregateError) throw error;
@@ -11573,6 +12500,18 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
11573
12500
  throw new AggregateError("server", `the request to ${resolved.providerName} failed: ${redactAggregateSecret(cause, resolved.apiKey)}`);
11574
12501
  } finally {
11575
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
+ });
11576
12515
  }
11577
12516
  }
11578
12517
  /**
@@ -12230,65 +13169,6 @@ function createWorkBuddyAdapter(options) {
12230
13169
  };
12231
13170
  }
12232
13171
  //#endregion
12233
- //#region src/billion-mount.ts
12234
- /** The package name the loader resolves, exactly as our patch row names it. */
12235
- const BILLION_PACKAGE = "billion-context";
12236
- /**
12237
- * Is this package installed and able to run?
12238
- *
12239
- * Any entry whose name matches counts — including one whose fiber has not
12240
- * started yet. When this function still decided whether to mount, a fiber-less
12241
- * entry had to be treated as absent: treating an import-in-flight as present
12242
- * would have skipped the mount and left the compressor never starting. Now that
12243
- * nothing mounts, the distinction only picks a label, and the honest one is
12244
- * "installed": a user watching billion-context's own boot does not want the
12245
- * card telling them it is missing while its proxy is half-way up.
12246
- *
12247
- * A DISABLED entry does not count. `loader.entries()` yields disabled rows too
12248
- * — the host skips starting them, it does not drop them — and our own patch
12249
- * row is disabled on purpose. Counting it would make every profile that never
12250
- * installed billion-context read as "installed", which is precisely the lie the
12251
- * card must not tell.
12252
- */
12253
- function mountedInLoader(loader, packageName) {
12254
- for (const entry of loader.entries()) {
12255
- if (entry.options?.name !== packageName) continue;
12256
- if (entry.options?.disabled === true) continue;
12257
- return true;
12258
- }
12259
- return false;
12260
- }
12261
- /**
12262
- * Report whether the compressor is already running. Never mounts.
12263
- *
12264
- * Resolves rather than rejects on every path: a compressor that will not start
12265
- * is a card that says so, never a pool that fails to boot. The pool's own model
12266
- * registry has nothing to do with compression, and one missing optional feature
12267
- * must not take the accounts down with it.
12268
- */
12269
- async function mountBillionContext(ctx, logger) {
12270
- try {
12271
- let loader;
12272
- ctx.inject(["loader"], (scoped) => {
12273
- loader = scoped.loader;
12274
- });
12275
- if (loader === void 0) return {
12276
- state: "unavailable",
12277
- reason: "the host exposes no plugin loader"
12278
- };
12279
- if (mountedInLoader(loader, BILLION_PACKAGE)) {
12280
- logger.info?.(`[xdpool] billion-context is already mounted; leaving it alone`);
12281
- return { state: "already-mounted" };
12282
- }
12283
- return { state: "not-required" };
12284
- } catch (error) {
12285
- return {
12286
- state: "unavailable",
12287
- reason: error instanceof Error ? error.message : String(error)
12288
- };
12289
- }
12290
- }
12291
- //#endregion
12292
13172
  //#region src/pack-accounts.ts
12293
13173
  /**
12294
13174
  * Channel ids whose accounts this plugin serves itself.
@@ -12343,6 +13223,7 @@ function packCredentialToWorkBuddy(entry, document) {
12343
13223
  const expiresAtMs = expiryToMs(document.expires_at) || expiryToMs(entry.expiresAt);
12344
13224
  const nickname = text$3(document.nickname) ?? text$3(entry.nickname);
12345
13225
  const uid = text$3(document.user_id);
13226
+ const enterpriseId = text$3(document.enterprise_id);
12346
13227
  return {
12347
13228
  accessToken,
12348
13229
  refreshToken: text$3(document.refresh_token) ?? "",
@@ -12350,7 +13231,7 @@ function packCredentialToWorkBuddy(entry, document) {
12350
13231
  ...expiryToMs(document.refresh_expires_at) > 0 ? { refreshExpiresAtMs: expiryToMs(document.refresh_expires_at) } : {},
12351
13232
  ...nickname === void 0 ? {} : { nickname },
12352
13233
  ...uid === void 0 ? {} : { uid },
12353
- ...text$3(document.enterprise_id) === void 0 ? {} : { enterpriseId: text$3(document.enterprise_id) },
13234
+ ...enterpriseId === void 0 ? {} : { enterpriseId },
12354
13235
  domain,
12355
13236
  sourcePath: `channel-pack:${provider}:${refName}`
12356
13237
  };
@@ -12486,31 +13367,123 @@ function imageUrlOf(block, resolveImage, warnings) {
12486
13367
  ...typeof block.path === "string" ? { path: block.path } : {},
12487
13368
  ...typeof block.url === "string" ? { url: block.url } : {}
12488
13369
  });
12489
- if (resolved !== void 0 && resolved !== "") return resolved;
13370
+ if (resolved !== void 0 && resolved !== "") return resolved;
13371
+ }
13372
+ const nested = block.image_url;
13373
+ const direct = typeof nested === "string" ? nested : text$2(block.url ?? block.data ?? (isRecord$2(nested) ? nested.url : void 0));
13374
+ if (direct.startsWith("data:")) return direct;
13375
+ if (direct.startsWith("http://") || direct.startsWith("https://")) return direct;
13376
+ warnings.push("image-dropped");
13377
+ }
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
+ });
12490
13448
  }
12491
- const nested = block.image_url;
12492
- const direct = typeof nested === "string" ? nested : text$2(block.url ?? block.data ?? (isRecord$2(nested) ? nested.url : void 0));
12493
- if (direct.startsWith("data:")) return direct;
12494
- if (direct.startsWith("http://") || direct.startsWith("https://")) return direct;
12495
- warnings.push("image-dropped");
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;
12496
13452
  }
12497
- /** Every tool result a message carries, in order. */
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
+ */
12498
13468
  function toolResultsOf(message) {
12499
- if (message.role !== "tool") {
12500
- const blocks = blocksOf(message.content).filter((block) => block.type === "tool-result");
12501
- 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);
12502
13472
  return [{
12503
- callId: text$2(message.tool_call_id ?? message.toolCallId ?? message.id),
12504
- 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 } : {}
12505
13479
  }];
12506
13480
  }
12507
- const out = [];
12508
13481
  const blocks = blocksOf(message.content).filter((block) => block.type === "tool-result");
12509
- for (const block of blocks) out.push({
12510
- callId: text$2(block.tool_use_id ?? block.toolCallId ?? message.tool_call_id ?? message.id),
12511
- content: [block]
12512
- });
12513
- 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
+ }];
12514
13487
  }
12515
13488
  /** The tool call a message's `tool_calls` array holds, when it holds one. */
12516
13489
  function toolCallsOf(message) {
@@ -12575,7 +13548,7 @@ function toolDefsFor(tools, wire) {
12575
13548
  */
12576
13549
  function requestBodyFor(wire, body, options) {
12577
13550
  const { modelId, resolveImage, warnings } = options;
12578
- 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);
12579
13552
  const stream = body.stream === true;
12580
13553
  const reasoningEffort = text$2(body.reasoning_effort);
12581
13554
  const out = {
@@ -12751,7 +13724,8 @@ function claudeTurns(messages, resolveImage, warnings) {
12751
13724
  content: [{
12752
13725
  type: "tool_result",
12753
13726
  tool_use_id: result.callId,
12754
- 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 } : {}
12755
13729
  }]
12756
13730
  });
12757
13731
  }
@@ -13758,6 +14732,33 @@ function withSessionQuotaHint(error, retryAfterMs) {
13758
14732
  return new AggregateError(error.kind, `${error.message} — this quota is counted per conversation on the free tier, so a new conversation (or another model) starts with its own budget`, error.status);
13759
14733
  }
13760
14734
  /**
14735
+ * Say what a 403 on this lane actually means (#96).
14736
+ *
14737
+ * The gateway answers a client it does not recognise with `403 FreeTierError`
14738
+ * and no explanation of which of its four fingerprint facts was wrong. That
14739
+ * reaches the caller as a bare `client` failure, and a bare 4xx is what the
14740
+ * host then renders in its own words: the user in #96 saw "当前请求的额度已用尽"
14741
+ * for a request that had never been billed. The wording pointed at waiting,
14742
+ * while the actual cause was "this build's fingerprint no longer matches, update
14743
+ * the plugin" — the two need opposite responses.
14744
+ *
14745
+ * The lane cannot tell them apart from the outside (both look like a refusal
14746
+ * with a thin body), so this does not pretend to. It names the actionable
14747
+ * reading FIRST — the fingerprint, which the user can fix by updating — and
14748
+ * keeps the quota possibility second, because it is the one that fixes itself.
14749
+ *
14750
+ * A 429 keeps its own hint ({@link withSessionQuotaHint}): that one really is
14751
+ * quota, and blurring the two would lose a distinction worth keeping.
14752
+ */
14753
+ function withFreeTierHint(status, refusal, retryAfterMs) {
14754
+ const base = classifyUpstream(status, refusal, retryAfterMs, "");
14755
+ if (status !== 403) return base;
14756
+ const separator = base.message.indexOf(": ");
14757
+ const detail = separator === -1 ? "" : base.message.slice(separator + 2);
14758
+ const kept = detail === "" ? "" : ` (provider said: ${detail})`;
14759
+ return new AggregateError(base.kind, `the free lane refused this client: its request fingerprint no longer matches what opencode.ai accepts, so this is NOT an exhausted quota and waiting will not help — update dsh-xdpool and try again; if it persists, report this message. The shared free quota is also metered per conversation, so one retry in a fresh conversation covers the case where only that budget is spent${kept}`, status);
14760
+ }
14761
+ /**
13761
14762
  * POST one streaming turn and hand back chat-shaped deltas.
13762
14763
  *
13763
14764
  * The shape of the ANSWER decides how it is read, never its Content-Type: a front
@@ -13801,7 +14802,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13801
14802
  const retryAfter = retryAfterMs(response);
13802
14803
  if (!response.ok) {
13803
14804
  const refusal = await response.text().catch(() => "");
13804
- throw withSessionQuotaHint(classifyUpstream(response.status, refusal, retryAfter, ""), retryAfter);
14805
+ throw withSessionQuotaHint(withFreeTierHint(response.status, refusal, retryAfter), retryAfter);
13805
14806
  }
13806
14807
  if (response.body === null) throw new AggregateError("server", "the free lane returned no body", response.status);
13807
14808
  let events;
@@ -13834,7 +14835,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13834
14835
  } catch {
13835
14836
  continue;
13836
14837
  }
13837
- if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(classifyUpstream(response.status, event, retryAfter, ""), retryAfter);
14838
+ if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(withFreeTierHint(response.status, event, retryAfter), retryAfter);
13838
14839
  const translated = chatChunksFor(wire, decoded, tools);
13839
14840
  for (const delta of translated.frames) hooks.onDelta(delta);
13840
14841
  if (translated.usage !== void 0) usage = translated.usage;
@@ -13849,7 +14850,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13849
14850
  result.usage = usage;
13850
14851
  }
13851
14852
  result.suppressedOnly = tools.suppressed();
13852
- result.finishReason = finish;
14853
+ if (finish !== void 0) result.finishReason = finish;
13853
14854
  return result;
13854
14855
  }
13855
14856
  /**
@@ -14057,542 +15058,213 @@ function capabilitiesFor(modelId) {
14057
15058
  canDisableThinking: true
14058
15059
  };
14059
15060
  }
14060
- /** Is this id's availability known to depend on where the request leaves from? */
14061
- function isRegionSensitive(modelId) {
14062
- const base = baseModelId(modelId);
14063
- return REGION_SENSITIVE.some((pattern) => pattern.test(base));
14064
- }
14065
- /** Title-case a bare upstream id into something a picker can show. */
14066
- function displayModelName(modelId) {
14067
- const base = baseModelId(modelId);
14068
- const known = DISPLAY_NAMES[base];
14069
- if (known !== void 0) return known;
14070
- return base.replace(/[-_.]+/gu, " ").replace(/(\d)\s+/gu, "$1 ").trim().split(" ").map((word) => /^\d/u.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
14071
- }
14072
- /** Parse the gateway's `{"data":[{"id":…}]}` listing. */
14073
- function parseListing(payload) {
14074
- const rows = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : Array.isArray(payload) ? payload : [];
14075
- const out = [];
14076
- for (const row of rows) {
14077
- const id = typeof row === "string" ? row : row?.id;
14078
- if (typeof id === "string" && id !== "") out.push(id);
14079
- }
14080
- return out;
14081
- }
14082
- /**
14083
- * The free rows for a raw listing, in listing order.
14084
- *
14085
- * The prefix is what the shim routes on, so it is applied here rather than at
14086
- * the call sites: one place decides the lane's id shape, and every reader gets it
14087
- * the same way.
14088
- */
14089
- function buildFreeCatalog(ids) {
14090
- const seen = /* @__PURE__ */ new Set();
14091
- const rows = [];
14092
- for (const raw of ids) {
14093
- const id = raw.trim();
14094
- if (id === "" || !isFreeLane(id)) continue;
14095
- const base = baseModelId(id);
14096
- if (seen.has(base)) continue;
14097
- seen.add(base);
14098
- const caps = capabilitiesFor(base);
14099
- rows.push({
14100
- id: base,
14101
- modelId: `${FREE_MODEL_PREFIX}${base}`,
14102
- name: displayModelName(base),
14103
- wire: wireFor(base),
14104
- vision: caps.vision,
14105
- reasoning: caps.reasoning,
14106
- contextWindow: positiveInt(caps.contextWindow) ?? FALLBACK_CAPABILITY.contextWindow,
14107
- maxOutput: positiveInt(caps.maxOutput) ?? FALLBACK_CAPABILITY.maxOutput,
14108
- canDisableThinking: caps.canDisableThinking,
14109
- regionSensitive: isRegionSensitive(base)
14110
- });
14111
- }
14112
- return rows;
14113
- }
14114
- /** One listing round. Returns the parsed JSON document. */
14115
- async function fetchFreeListing(options = {}) {
14116
- const controller = new AbortController();
14117
- const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 15e3);
14118
- timer.unref?.();
14119
- let callerAborted = false;
14120
- const onCallerAbort = () => {
14121
- callerAborted = true;
14122
- controller.abort();
14123
- };
14124
- options.signal?.addEventListener("abort", onCallerAbort, { once: true });
14125
- try {
14126
- const response = await freeFetch(`${FREE_UPSTREAM_BASE}${FREE_LISTING_PATH}`, {
14127
- headers: gatewayHeaders({
14128
- session: sessionForConversation(options.session),
14129
- requestId: mintRequestId(),
14130
- stream: false,
14131
- accept: "application/json"
14132
- }),
14133
- signal: controller.signal
14134
- });
14135
- const text = await response.text();
14136
- if (!response.ok) throw new Error(`the free lane answered HTTP ${response.status}: ${text.slice(0, 200)}`);
14137
- return JSON.parse(text);
14138
- } catch (error) {
14139
- if (callerAborted || options.signal?.aborted === true) throw new Error("request aborted");
14140
- if (error instanceof Error && error.name === "AbortError") throw new Error(`the free lane's listing did not answer within ${String(options.timeoutMs ?? 15e3)}ms`);
14141
- throw error instanceof Error ? error : new Error(String(error));
14142
- } finally {
14143
- clearTimeout(timer);
14144
- options.signal?.removeEventListener("abort", onCallerAbort);
14145
- }
14146
- }
14147
- /**
14148
- * The static roster, used until the first listing lands and again whenever one
14149
- * fails.
14150
- *
14151
- * An empty picker is the one outcome a user reads as "the plugin broke", so the
14152
- * lane ships with rows it can serve without ever having reached the network.
14153
- * These are the ids the gateway is known to serve免密 today; a row that later
14154
- * disappears costs one dead model entry, while an empty lane costs everything.
14155
- */
14156
- const FALLBACK_FREE_MODEL_IDS = Object.freeze([
14157
- "muse-spark-1.3-contributor-free",
14158
- "muse-spark-1.2-contributor-free",
14159
- "union-alpha",
14160
- "space-bunny-free",
14161
- "nemotron-3-ultra-free",
14162
- "deepseek-v4-flash-free",
14163
- "mimo-v2.6-flash-free",
14164
- "mimo-v2.5-free",
14165
- "jev-1.13-free"
14166
- ]);
14167
- /** Live free roster with a static fallback behind it. */
14168
- var FreeCatalog = class {
14169
- rows;
14170
- listeners = /* @__PURE__ */ new Set();
14171
- lastError;
14172
- lastUpdatedAt;
14173
- constructor(fallback = FALLBACK_FREE_MODEL_IDS) {
14174
- this.rows = Object.freeze(buildFreeCatalog(fallback));
14175
- }
14176
- current() {
14177
- return this.rows;
14178
- }
14179
- /** Why the last refresh failed, for display. Never a substitute for rows. */
14180
- error() {
14181
- return this.lastError;
14182
- }
14183
- /** When the live list last landed. */
14184
- updatedAt() {
14185
- return this.lastUpdatedAt;
14186
- }
14187
- /** The row behind a published id, or undefined. */
14188
- find(modelId) {
14189
- return this.rows.find((row) => row.modelId === modelId || row.id === modelId);
14190
- }
14191
- /** Whether anything is being served right now. */
14192
- hasRows() {
14193
- return this.rows.length > 0;
14194
- }
14195
- onChange(listener) {
14196
- this.listeners.add(listener);
14197
- return () => {
14198
- this.listeners.delete(listener);
14199
- };
14200
- }
14201
- /**
14202
- * Re-read the listing and notify when the roster actually changed.
14203
- *
14204
- * A failed refresh KEEPS the current rows and records the error. Dropping to
14205
- * empty here would make a network hiccup look like the models were deleted.
14206
- */
14207
- async refresh(options = {}) {
14208
- let next;
14209
- try {
14210
- next = Object.freeze(buildFreeCatalog(parseListing(await fetchFreeListing(options))));
14211
- } catch (error) {
14212
- this.lastError = error instanceof Error ? error.message : String(error);
14213
- if (this.rows.length === 0) this.rows = Object.freeze(buildFreeCatalog(FALLBACK_FREE_MODEL_IDS));
14214
- for (const listener of this.listeners) listener();
14215
- return this.rows;
14216
- }
14217
- if (next.length === 0) {
14218
- this.lastError = "the gateway listing held no免密 models";
14219
- for (const listener of this.listeners) listener();
14220
- return this.rows;
14221
- }
14222
- this.lastError = void 0;
14223
- this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
14224
- if (next.length === this.rows.length && next.every((row, index) => row.modelId === this.rows[index]?.modelId)) return this.rows;
14225
- this.rows = next;
14226
- for (const listener of this.listeners) listener();
14227
- return this.rows;
14228
- }
14229
- };
14230
- //#endregion
14231
- //#region src/stats.ts
14232
- /**
14233
- * Retained calls. Bounded on purpose: a busy week is thousands of calls, and a
14234
- * dashboard that grows without limit turns into a memory leak with a chart.
14235
- */
14236
- const USAGE_MAX_SAMPLES = 2e3;
14237
- /**
14238
- * Minimum total decoding time before a throughput figure is published.
14239
- *
14240
- * A one-token answer that arrives in 20ms would otherwise report a tps two
14241
- * orders of magnitude above anything a real turn reaches, and the panel would
14242
- * rank models by that artefact instead of by speed.
14243
- */
14244
- const MIN_DECODE_MS = 120;
14245
- /**
14246
- * A reported token count, or null when the key is absent / not a finite
14247
- * number.
14248
- *
14249
- * Zero is ACCEPTED: an empty turn really can cost nothing, and reporting that
14250
- * as "the gateway said nothing" would make the panel lie in the other
14251
- * direction.
14252
- */
14253
- function count(value) {
14254
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
14255
- }
14256
- function zero(value) {
14257
- return count(value) ?? 0;
14258
- }
14259
- function detailOf(value, key) {
14260
- if (typeof value !== "object" || value === null || Array.isArray(value)) return 0;
14261
- return zero(value[key]);
14262
- }
14263
- /**
14264
- * Read a gateway `usage` object, accepting both spellings OpenAI-compatible
14265
- * gateways use (`prompt_tokens` / `input_tokens`).
14266
- *
14267
- * Returns undefined when the document is not a usage report at all, so callers
14268
- * can tell "the gateway sent zeroes" from "there was nothing here".
14269
- */
14270
- function parseUsage(value) {
14271
- if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
14272
- const obj = value;
14273
- const input = count(obj["prompt_tokens"]) ?? count(obj["input_tokens"]);
14274
- const output = count(obj["completion_tokens"]) ?? count(obj["output_tokens"]);
14275
- if (input === null && output === null) return void 0;
14276
- return {
14277
- inputTokens: input ?? 0,
14278
- outputTokens: output ?? 0,
14279
- reasoningTokens: detailOf(obj["completion_tokens_details"], "reasoning_tokens"),
14280
- cachedTokens: detailOf(obj["prompt_tokens_details"], "cached_tokens")
14281
- };
14282
- }
14283
- /**
14284
- * True for the trailer frame a gateway sends to report usage.
14285
- *
14286
- * It is recognised by an EMPTY `choices` array beside a `usage` field. A frame
14287
- * that also carries content is a normal chunk that happens to mention usage,
14288
- * and dropping it would delete the user's answer - which is why the strip step
14289
- * downstream is allowed to remove exactly these and nothing else.
14290
- */
14291
- function isUsageOnlyFrame(parsed) {
14292
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
14293
- const obj = parsed;
14294
- if (obj["usage"] === void 0 || obj["usage"] === null) return false;
14295
- const choices = obj["choices"];
14296
- return Array.isArray(choices) && choices.length === 0;
15061
+ /** Is this id's availability known to depend on where the request leaves from? */
15062
+ function isRegionSensitive(modelId) {
15063
+ const base = baseModelId(modelId);
15064
+ return REGION_SENSITIVE.some((pattern) => pattern.test(base));
14297
15065
  }
14298
- /** Local calendar day of an instant, as `YYYY-MM-DD`. */
14299
- function usageDayKey(at) {
14300
- const d = new Date(at);
14301
- const month = String(d.getMonth() + 1).padStart(2, "0");
14302
- const day = String(d.getDate()).padStart(2, "0");
14303
- return `${d.getFullYear()}-${month}-${day}`;
15066
+ /** Title-case a bare upstream id into something a picker can show. */
15067
+ function displayModelName(modelId) {
15068
+ const base = baseModelId(modelId);
15069
+ const known = DISPLAY_NAMES[base];
15070
+ if (known !== void 0) return known;
15071
+ return base.replace(/[-_.]+/gu, " ").replace(/(\d)\s+/gu, "$1 ").trim().split(" ").map((word) => /^\d/u.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
15072
+ }
15073
+ /** Parse the gateway's `{"data":[{"id":…}]}` listing. */
15074
+ function parseListing(payload) {
15075
+ const rows = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : Array.isArray(payload) ? payload : [];
15076
+ const out = [];
15077
+ for (const row of rows) {
15078
+ const id = typeof row === "string" ? row : row?.id;
15079
+ if (typeof id === "string" && id !== "") out.push(id);
15080
+ }
15081
+ return out;
14304
15082
  }
14305
15083
  /**
14306
- * Timing and usage captured while one stream is forwarded.
15084
+ * The free rows for a raw listing, in listing order.
14307
15085
  *
14308
- * Kept free of Node stream types so it can be unit tested with plain numbers;
14309
- * the shim owns the Transform that feeds it chunks.
15086
+ * The prefix is what the shim routes on, so it is applied here rather than at
15087
+ * the call sites: one place decides the lane's id shape, and every reader gets it
15088
+ * the same way.
14310
15089
  */
14311
- var StreamTracker = class {
14312
- /** Milliseconds from request start to the first byte, once seen. */
14313
- ttftMs;
14314
- /** Milliseconds from first byte to the end of the stream, once finished. */
14315
- decodeMs;
14316
- /** Usage seen in any frame, if the gateway sent any. */
14317
- usage;
14318
- firstAt;
14319
- startedAt;
14320
- constructor(startedAt = Date.now()) {
14321
- this.startedAt = startedAt;
14322
- }
14323
- /** Record the first byte. Repeated calls keep the earliest. */
14324
- markFirstByte(at = Date.now()) {
14325
- if (this.firstAt !== void 0) return;
14326
- this.firstAt = at;
14327
- const ttft = at - this.startedAt;
14328
- if (ttft >= 0) this.ttftMs = ttft;
14329
- }
14330
- /** Record stream completion. Repeated calls keep the earliest. */
14331
- finish(at = Date.now()) {
14332
- if (this.decodeMs !== void 0) return;
14333
- if (this.firstAt === void 0) return;
14334
- const decode = at - this.firstAt;
14335
- if (decode >= 0) this.decodeMs = decode;
15090
+ function buildFreeCatalog(ids) {
15091
+ const seen = /* @__PURE__ */ new Set();
15092
+ const rows = [];
15093
+ for (const raw of ids) {
15094
+ const id = raw.trim();
15095
+ if (id === "" || !isFreeLane(id)) continue;
15096
+ const base = baseModelId(id);
15097
+ if (seen.has(base)) continue;
15098
+ seen.add(base);
15099
+ const caps = capabilitiesFor(base);
15100
+ rows.push({
15101
+ id: base,
15102
+ modelId: `${FREE_MODEL_PREFIX}${base}`,
15103
+ name: displayModelName(base),
15104
+ wire: wireFor(base),
15105
+ vision: caps.vision,
15106
+ reasoning: caps.reasoning,
15107
+ contextWindow: positiveInt(caps.contextWindow) ?? FALLBACK_CAPABILITY.contextWindow,
15108
+ maxOutput: positiveInt(caps.maxOutput) ?? FALLBACK_CAPABILITY.maxOutput,
15109
+ canDisableThinking: caps.canDisableThinking,
15110
+ regionSensitive: isRegionSensitive(base)
15111
+ });
14336
15112
  }
14337
- /** Fold a parsed frame's usage in; later frames overwrite, as the gateway intends. */
14338
- observe(parsed) {
14339
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
14340
- const usage = parseUsage(parsed["usage"]);
14341
- if (usage !== void 0) this.usage = usage;
15113
+ return rows;
15114
+ }
15115
+ /** One listing round. Returns the parsed JSON document. */
15116
+ async function fetchFreeListing(options = {}) {
15117
+ const controller = new AbortController();
15118
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 15e3);
15119
+ timer.unref?.();
15120
+ let callerAborted = false;
15121
+ const onCallerAbort = () => {
15122
+ callerAborted = true;
15123
+ controller.abort();
15124
+ };
15125
+ options.signal?.addEventListener("abort", onCallerAbort, { once: true });
15126
+ try {
15127
+ const response = await freeFetch(`${FREE_UPSTREAM_BASE}${FREE_LISTING_PATH}`, {
15128
+ headers: gatewayHeaders({
15129
+ session: sessionForConversation(options.session),
15130
+ requestId: mintRequestId(),
15131
+ stream: false,
15132
+ accept: "application/json"
15133
+ }),
15134
+ signal: controller.signal
15135
+ });
15136
+ const text = await response.text();
15137
+ if (!response.ok) throw new Error(`the free lane answered HTTP ${response.status}: ${text.slice(0, 200)}`);
15138
+ return JSON.parse(text);
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);
14342
15146
  }
14343
- };
15147
+ }
14344
15148
  /**
14345
- * 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.
14346
15151
  *
14347
- * In-memory by design: this is a live panel, and persisting every prompt's
14348
- * token counts would turn a convenience into a record of what the user asked.
14349
- * A restart therefore starts the chart again, which the panel states rather
14350
- * than hides.
14351
- */
14352
- var UsageRecorder = class {
14353
- samples = [];
14354
- limit;
14355
- constructor(limit = USAGE_MAX_SAMPLES) {
14356
- this.limit = limit > 0 ? limit : 1;
14357
- }
14358
- /** Retain one call, dropping the oldest once the window is full. */
14359
- record(call) {
14360
- this.samples.push(call);
14361
- while (this.samples.length > this.limit) this.samples.shift();
14362
- }
14363
- /** Samples currently retained. */
14364
- size() {
14365
- return this.samples.length;
14366
- }
14367
- /** Forget every retained call. */
14368
- reset() {
14369
- this.samples = [];
14370
- }
14371
- /** Aggregate the retained window. */
14372
- summary() {
14373
- const rows = /* @__PURE__ */ new Map();
14374
- const turns = /* @__PURE__ */ new Map();
14375
- let calls = 0;
14376
- let usageReported = false;
14377
- for (const call of this.samples) {
14378
- calls += 1;
14379
- if (call.usage !== void 0) usageReported = true;
14380
- const bucket = turns.get(call.turnId) ?? {
14381
- calls: 0,
14382
- succeeded: 0,
14383
- failed: 0
14384
- };
14385
- bucket.calls += 1;
14386
- if (call.ok) bucket.succeeded += 1;
14387
- else bucket.failed += 1;
14388
- turns.set(call.turnId, bucket);
14389
- const key = `${call.region}\u0000${call.model}`;
14390
- let acc = rows.get(key);
14391
- if (acc === void 0) {
14392
- acc = {
14393
- row: {
14394
- region: call.region,
14395
- model: call.model,
14396
- calls: 0,
14397
- turns: 0,
14398
- failed: 0,
14399
- recovered: 0,
14400
- inputTokens: 0,
14401
- outputTokens: 0,
14402
- reasoningTokens: 0,
14403
- cachedTokens: 0,
14404
- tps: null,
14405
- avgTtftMs: null,
14406
- lastUsedAt: 0
14407
- },
14408
- turnIds: /* @__PURE__ */ new Set(),
14409
- decodeMs: 0,
14410
- ttftTotal: 0,
14411
- ttftCount: 0
14412
- };
14413
- rows.set(key, acc);
14414
- }
14415
- acc.row.calls += 1;
14416
- acc.turnIds.add(call.turnId);
14417
- if (!call.ok) acc.row.failed += 1;
14418
- if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
14419
- if (call.ttftMs !== void 0) {
14420
- acc.ttftTotal += call.ttftMs;
14421
- acc.ttftCount += 1;
14422
- }
14423
- if (call.decodeMs !== void 0) acc.decodeMs += call.decodeMs;
14424
- if (call.usage !== void 0) {
14425
- acc.row.inputTokens += call.usage.inputTokens;
14426
- acc.row.outputTokens += call.usage.outputTokens;
14427
- acc.row.reasoningTokens += call.usage.reasoningTokens;
14428
- acc.row.cachedTokens += call.usage.cachedTokens;
14429
- }
14430
- }
14431
- const failedTurnIds = /* @__PURE__ */ new Set();
14432
- let recoveredTurns = 0;
14433
- for (const [turnId, bucket] of turns) if (bucket.failed > 0 && bucket.succeeded === 0) failedTurnIds.add(turnId);
14434
- else if (bucket.failed > 0) recoveredTurns += 1;
14435
- const out = [];
14436
- for (const acc of rows.values()) {
14437
- let recovered = 0;
14438
- for (const turnId of acc.turnIds) {
14439
- const bucket = turns.get(turnId);
14440
- if (bucket !== void 0 && bucket.failed > 0 && bucket.succeeded > 0) recovered += 1;
14441
- }
14442
- out.push({
14443
- ...acc.row,
14444
- turns: acc.turnIds.size,
14445
- recovered,
14446
- tps: acc.decodeMs >= MIN_DECODE_MS ? Math.round(acc.row.outputTokens / (acc.decodeMs / 1e3)) : null,
14447
- avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftTotal / acc.ttftCount) : null
14448
- });
14449
- }
14450
- out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.model.localeCompare(b.model));
14451
- return {
14452
- samples: this.samples.length,
14453
- turns: turns.size,
14454
- failedTurns: failedTurnIds.size,
14455
- recoveredTurns,
14456
- calls,
14457
- usageReported,
14458
- entries: this.samples.slice().reverse(),
14459
- rows: out,
14460
- accounts: this.accountRows(),
14461
- days: this.days(failedTurnIds)
14462
- };
14463
- }
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.
15156
+ */
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;
14464
15174
  /**
14465
- * One row per account, busiest first.
15175
+ * Whether this lane contributes models at all.
14466
15176
  *
14467
- * Samples that never named an account are grouped under an empty label
14468
- * rather than dropped: `account` is optional on `UsageCall`, so a caller that
14469
- * files a sample without one - a failure raised before any account was ever
14470
- * picked, say - would otherwise vanish from this view, and the account table
14471
- * would add up to less than the call total with nothing on screen to explain
14472
- * the gap. Every shim record site names its account; this branch exists so
14473
- * 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 "关不掉,应该默认是关闭的".
14474
15184
  *
14475
- * `topModels` keeps the two most-used ids rather than every one: this column
14476
- * answers "what does this account usually run", and a list of nine ids is not
14477
- * 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.
14478
15187
  */
14479
- accountRows() {
14480
- const buckets = /* @__PURE__ */ new Map();
14481
- for (const call of this.samples) {
14482
- const label = call.account ?? "";
14483
- const key = `${call.region}\u0000${label}`;
14484
- let acc = buckets.get(key);
14485
- if (acc === void 0) {
14486
- acc = {
14487
- row: {
14488
- account: label,
14489
- region: call.region,
14490
- calls: 0,
14491
- turns: 0,
14492
- failed: 0,
14493
- inputTokens: 0,
14494
- outputTokens: 0,
14495
- cachedTokens: 0,
14496
- lastUsedAt: 0,
14497
- topModels: []
14498
- },
14499
- turnIds: /* @__PURE__ */ new Set(),
14500
- modelCalls: /* @__PURE__ */ new Map()
14501
- };
14502
- buckets.set(key, acc);
14503
- }
14504
- acc.row.calls += 1;
14505
- acc.turnIds.add(call.turnId);
14506
- if (!call.ok) acc.row.failed += 1;
14507
- if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
14508
- acc.modelCalls.set(call.model, (acc.modelCalls.get(call.model) ?? 0) + 1);
14509
- if (call.usage !== void 0) {
14510
- acc.row.inputTokens += call.usage.inputTokens;
14511
- acc.row.outputTokens += call.usage.outputTokens;
14512
- acc.row.cachedTokens += call.usage.cachedTokens;
14513
- }
14514
- }
14515
- const out = [];
14516
- for (const acc of buckets.values()) {
14517
- const topModels = [...acc.modelCalls.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 2).map(([model]) => model);
14518
- out.push({
14519
- ...acc.row,
14520
- turns: acc.turnIds.size,
14521
- topModels
14522
- });
14523
- }
14524
- out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.account.localeCompare(b.account));
14525
- 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;
14526
15201
  }
14527
15202
  /**
14528
- * The daily series, oldest first, limited to the most recent days.
14529
- *
14530
- * Sorted by key rather than insertion order because a retained window can
14531
- * straddle a month boundary, and a chart whose axis jumps backwards reads as
14532
- * corrupted data rather than as history.
15203
+ * The rows that may be OFFERED.
14533
15204
  *
14534
- * `failedTurns` is counted by DISTINCT turn id, not by summing per-call
14535
- * failures: a turn that rotated three accounts and failed on all three is
14536
- * still one failed turn, and the whole reason this module keeps turns and
14537
- * 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.
14538
15209
  */
14539
- days(failedTurnIds) {
14540
- const buckets = /* @__PURE__ */ new Map();
14541
- for (const call of this.samples) {
14542
- const key = usageDayKey(call.at);
14543
- let acc = buckets.get(key);
14544
- if (acc === void 0) {
14545
- acc = {
14546
- day: key,
14547
- calls: 0,
14548
- inputTokens: 0,
14549
- outputTokens: 0,
14550
- failedTurnIds: /* @__PURE__ */ new Set()
14551
- };
14552
- buckets.set(key, acc);
14553
- }
14554
- acc.calls += 1;
14555
- acc.inputTokens += call.usage?.inputTokens ?? 0;
14556
- acc.outputTokens += call.usage?.outputTokens ?? 0;
14557
- if (failedTurnIds.has(call.turnId)) acc.failedTurnIds.add(call.turnId);
14558
- }
14559
- return [...buckets.values()].sort((a, b) => a.day.localeCompare(b.day)).slice(-14).map((acc) => ({
14560
- day: acc.day,
14561
- calls: acc.calls,
14562
- failedTurns: acc.failedTurnIds.size,
14563
- inputTokens: acc.inputTokens,
14564
- outputTokens: acc.outputTokens
14565
- }));
15210
+ current() {
15211
+ return this.laneEnabled ? this.rows : [];
14566
15212
  }
14567
- };
14568
- /**
14569
- * Inspect one SSE frame's content, without its trailing separator.
14570
- *
14571
- * Returns undefined when no `data:` line parsed, which covers the `[DONE]`
14572
- * sentinel, keep-alive comments and truncated JSON alike - a frame the forwarder
14573
- * has no opinion about must come back untouched rather than guessed at.
14574
- */
14575
- function inspectSseFrame(frame) {
14576
- for (const line of frame.split("\n")) {
14577
- const trimmed = line.trim();
14578
- if (!trimmed.startsWith("data:")) continue;
14579
- const payload = trimmed.slice(5).trim();
14580
- if (payload === "" || payload === "[DONE]") continue;
14581
- let parsed;
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;
15224
+ }
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;
14582
15247
  try {
14583
- parsed = JSON.parse(payload);
14584
- } catch {
14585
- 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;
14586
15254
  }
14587
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
14588
- const usage = parsed["usage"];
14589
- if (usage === void 0 || usage === null) continue;
14590
- return {
14591
- parsed,
14592
- usageOnly: isUsageOnlyFrame(parsed)
14593
- };
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;
14594
15266
  }
14595
- }
15267
+ };
14596
15268
  //#endregion
14597
15269
  //#region src/free-lane.ts
14598
15270
  /**
@@ -14703,6 +15375,7 @@ function createFreeLaneSource(catalog, logger, usageRecorder) {
14703
15375
  } finally {
14704
15376
  tracker.finish(Date.now());
14705
15377
  usageRecorder?.record({
15378
+ channel: "free",
14706
15379
  region: "free",
14707
15380
  model: modelId,
14708
15381
  account: "free",
@@ -15051,6 +15724,71 @@ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
15051
15724
  };
15052
15725
  }
15053
15726
  /**
15727
+ * Pull the `expertId` out of a task's `jump_url`.
15728
+ *
15729
+ * The "体验XX专家" tasks (#98) name an expert that is NOT in the marketplace
15730
+ * listing — the reporter翻遍了 marketExpertList in both `agent` and `team`
15731
+ * types and got zero hits — so the only place the id appears is the deeplink
15732
+ * embedded in the task itself:
15733
+ *
15734
+ * https://www.workbuddy.cn/work/launch/?deeplink=workbuddy%3A%2F%2Fexperts%3FexpertId%3Dex_GDl31WahyIH0&buddy_type=workbuddy
15735
+ *
15736
+ * The deeplink is percent-encoded, and it has to be: the `?` and `&` inside it
15737
+ * would otherwise end the outer query. So the parse is two steps — decode the
15738
+ * `deeplink` parameter, then read `expertId` off the scheme it decodes to — and
15739
+ * NOT a regex over the whole URL, which would also match the outer `expertId`
15740
+ * spelling in a future URL shape and silently pick the wrong one.
15741
+ *
15742
+ * Returns undefined for the tasks whose deeplink carries no id at all
15743
+ * (`WorkBuddy_Valorant` points at `workbuddy://expert` with no query), which is
15744
+ * the honest answer: those need a different source and are not guessed here.
15745
+ */
15746
+ function expertIdFromJumpUrl(jumpUrl) {
15747
+ if (jumpUrl === "") return void 0;
15748
+ /** Read `expertId` off any URL-ish string, decoded or not. */
15749
+ const readId = (value) => {
15750
+ let query = value;
15751
+ try {
15752
+ const parsed = new URL(value);
15753
+ query = parsed.search === "" ? value : parsed.search;
15754
+ } catch {
15755
+ query = value;
15756
+ }
15757
+ const marker = query.indexOf("expertId=");
15758
+ if (marker === -1) return void 0;
15759
+ const rest = query.slice(marker + 9);
15760
+ const end = rest.search(/[&#]/u);
15761
+ const id = (end === -1 ? rest : rest.slice(0, end)).trim();
15762
+ return id === "" ? void 0 : id;
15763
+ };
15764
+ let source = jumpUrl;
15765
+ try {
15766
+ const raw = new URL(jumpUrl).searchParams.get("deeplink");
15767
+ if (raw !== null && raw !== "") source = raw;
15768
+ } catch {
15769
+ source = jumpUrl;
15770
+ }
15771
+ return readId(source);
15772
+ }
15773
+ /**
15774
+ * Build a summon record for an expert the marketplace does not list.
15775
+ *
15776
+ * Everything the chains need beyond the id is cosmetic — the scorer keys on
15777
+ * `expertId` — so the title from the task is reused as the display name rather
15778
+ * than inventing one. `version` is left empty, which every consumer already
15779
+ * defaults to `1.0.0`.
15780
+ */
15781
+ function expertFromTask(expertId, title) {
15782
+ return {
15783
+ expertId,
15784
+ expertType: "agent",
15785
+ displayName: title,
15786
+ profession: title,
15787
+ version: "",
15788
+ categories: []
15789
+ };
15790
+ }
15791
+ /**
15054
15792
  * The three "summon an expert" events (`expert_summon_click` and friends).
15055
15793
  *
15056
15794
  * Paid before the conversation, in the order the app emits them.
@@ -15173,7 +15911,12 @@ const EVENT_CHAIN_BUILDERS = {
15173
15911
  skill_1: true,
15174
15912
  expert_5: true,
15175
15913
  Expert_team_use_3: true,
15176
- Expert_lighthouse: true
15914
+ Expert_lighthouse: true,
15915
+ expert_use_maidehao: true,
15916
+ expert_use_douyinyin: true,
15917
+ expert_use_shangjia_xuanchuan: true,
15918
+ expert_use_bowangwang: true,
15919
+ WorkBuddy_Valorant: true
15177
15920
  };
15178
15921
  const AUTOMATION_TICK_MS = 6e4;
15179
15922
  /** The jobs in the order a tick runs them: report before tasks, always. */
@@ -15307,10 +16050,35 @@ function isFireHour(now, hours) {
15307
16050
  const { hour } = zonedParts(now);
15308
16051
  return hours.includes(Number(hour));
15309
16052
  }
15310
- /** Whether this account may be used: not switched off, not cooling. */
15311
- 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) {
15312
16080
  if (account.cooldownUntilMs > now) return false;
15313
- if (regionOf(account.credential.domain) === "global") return false;
16081
+ if (job !== void 0 && !jobAllowsRegion(job, regionOf(account.credential.domain))) return false;
15314
16082
  return true;
15315
16083
  }
15316
16084
  const sleep$1 = (ms) => new Promise((resolve) => {
@@ -15381,6 +16149,16 @@ var WorkBuddyScheduler = class {
15381
16149
  /** Host hooks that persist the ledger across restarts. */
15382
16150
  loadEarnings;
15383
16151
  saveEarningsFn;
16152
+ /**
16153
+ * The most recent ledger write that may still be in flight.
16154
+ *
16155
+ * `persistEarnings` is synchronous from its callers' point of view, but the
16156
+ * host's settings write is not: it is an async document update. Keeping the
16157
+ * promise here is what lets {@link flushEarnings} wait for the write that
16158
+ * matters — the last one before a restart — without making every counter
16159
+ * update await disk.
16160
+ */
16161
+ pendingEarningsWrite = Promise.resolve();
15384
16162
  constructor(pool, client, options = {}) {
15385
16163
  this.pool = pool;
15386
16164
  this.client = client;
@@ -15417,6 +16195,18 @@ var WorkBuddyScheduler = class {
15417
16195
  this.saveEarningsFn = save;
15418
16196
  }
15419
16197
  /**
16198
+ * Wait for the ledger write that is (or was last) in flight.
16199
+ *
16200
+ * Called at the END of a task pass, right after the last claim, so the rewards
16201
+ * that were just collected are on disk before a restart can lose them. It is
16202
+ * deliberately NOT awaited on every counter bump: the settings document write
16203
+ * is the slow part, and paying it per account would slow the pass for no
16204
+ * benefit while the process is still alive.
16205
+ */
16206
+ async flushEarnings() {
16207
+ await this.pendingEarningsWrite;
16208
+ }
16209
+ /**
15420
16210
  * Fold a previously persisted ledger back in, when it belongs to today.
15421
16211
  *
15422
16212
  * Used after the settings document becomes readable, which happens after
@@ -15686,7 +16476,7 @@ var WorkBuddyScheduler = class {
15686
16476
  compensationCredit: (existing?.compensationCredit ?? 0) + compensationCredit,
15687
16477
  date: today
15688
16478
  });
15689
- this.persistEarnings();
16479
+ this.pendingEarningsWrite = this.persistEarnings();
15690
16480
  }
15691
16481
  /** Clear the per-account counters when the local day changes. */
15692
16482
  rollEarnings(today) {
@@ -15702,9 +16492,18 @@ var WorkBuddyScheduler = class {
15702
16492
  * Best effort on purpose: a failed save must never abort a run that has
15703
16493
  * already collected rewards, and the in-memory ledger keeps serving the card
15704
16494
  * for the rest of the session either way.
16495
+ *
16496
+ * The returned promise settles when the write has landed (or failed). Callers
16497
+ * that are about to END a run await it: reported in #98, a task pass that
16498
+ * claimed 21 tasks had its process restarted before the volatile settings
16499
+ * write reached disk, so `automationEarnings` still read `credit: 0` while
16500
+ * the upstream had really paid out. Fire-and-forget is fine for a counter the
16501
+ * card will re-read in this process; it is not fine for the last write before
16502
+ * exit.
15705
16503
  */
15706
16504
  persistEarnings() {
15707
- if (this.saveEarningsFn === void 0) return;
16505
+ const save = this.saveEarningsFn;
16506
+ if (save === void 0) return Promise.resolve();
15708
16507
  const accounts = {};
15709
16508
  for (const [id, entry] of this.earnings) accounts[id] = entry;
15710
16509
  const ledger = {
@@ -15712,12 +16511,14 @@ var WorkBuddyScheduler = class {
15712
16511
  accounts
15713
16512
  };
15714
16513
  try {
15715
- const saved = this.saveEarningsFn(ledger);
15716
- if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
16514
+ const saved = save(ledger);
16515
+ if (saved !== void 0 && typeof saved.then === "function") return Promise.resolve(saved).then(void 0, (error) => {
15717
16516
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
15718
16517
  });
16518
+ return Promise.resolve();
15719
16519
  } catch (error) {
15720
16520
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
16521
+ return Promise.resolve();
15721
16522
  }
15722
16523
  }
15723
16524
  /**
@@ -15744,8 +16545,22 @@ var WorkBuddyScheduler = class {
15744
16545
  let energy = 0;
15745
16546
  let claimed = 0;
15746
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 = [];
15747
16556
  let progressNote;
15748
- 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);
15749
16564
  if (kind === "tasks") await this.sendEventChains(accounts);
15750
16565
  for (let index = 0; index < accounts.length; index++) {
15751
16566
  if (this.stopped) break;
@@ -15753,12 +16568,22 @@ var WorkBuddyScheduler = class {
15753
16568
  if (account === void 0) continue;
15754
16569
  try {
15755
16570
  switch (kind) {
15756
- 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
+ }
15757
16582
  try {
15758
16583
  const claim = await this.client.claimDailyCheckin(account.credential);
15759
16584
  this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
15760
16585
  if (claim.credit > 0) {
15761
- if (claim.credit > 0) detail.push(`签到 +${claim.credit}`);
16586
+ detail.push(`签到 +${claim.credit}`);
15762
16587
  this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
15763
16588
  }
15764
16589
  } catch (error) {
@@ -15766,6 +16591,7 @@ var WorkBuddyScheduler = class {
15766
16591
  this.logger.info?.(`automation checkin ${account.label}: already done today`);
15767
16592
  }
15768
16593
  break;
16594
+ }
15769
16595
  case "report": {
15770
16596
  const days = await this.reportOne(account);
15771
16597
  this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
@@ -15777,6 +16603,7 @@ var WorkBuddyScheduler = class {
15777
16603
  energy += result.energy;
15778
16604
  claimed += result.claimed;
15779
16605
  detail.push(...result.titles);
16606
+ items.push(...result.items);
15780
16607
  this.claimableSeen += result.claimableCount;
15781
16608
  this.recordEarnings(account.id, today, {
15782
16609
  credit: result.credit,
@@ -15824,8 +16651,11 @@ var WorkBuddyScheduler = class {
15824
16651
  state.claimed = claimed;
15825
16652
  state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
15826
16653
  state.detail = detail;
15827
- state.progress = progressNote;
16654
+ state.items = items;
16655
+ if (progressNote === void 0) delete state.progress;
16656
+ else state.progress = progressNote;
15828
16657
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
16658
+ if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
15829
16659
  }
15830
16660
  /** Compose the one-line summary shown on the card. */
15831
16661
  summarise(kind, ok, failed, claimed, credit, energy) {
@@ -15837,12 +16667,16 @@ var WorkBuddyScheduler = class {
15837
16667
  *
15838
16668
  * Disabled accounts are excluded here rather than filtered by the caller so a
15839
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.
15840
16674
  */
15841
- accountsInOrder() {
16675
+ accountsInOrder(job) {
15842
16676
  const now = Date.now();
15843
16677
  return this.pool.list().filter((account) => {
15844
16678
  if (this.pool.isDisabled(account.id)) return false;
15845
- return eligible(account, now);
16679
+ return eligible(account, now, job);
15846
16680
  });
15847
16681
  }
15848
16682
  /**
@@ -15948,10 +16782,88 @@ var WorkBuddyScheduler = class {
15948
16782
  case "expert_5": return this.expertChains(credential, "agent", task);
15949
16783
  case "Expert_team_use_3": return this.expertChains(credential, "team", task);
15950
16784
  case "Expert_lighthouse": return this.lighthouseChains(credential);
16785
+ case "expert_use_maidehao":
16786
+ case "expert_use_douyinyin":
16787
+ case "expert_use_shangjia_xuanchuan":
16788
+ case "expert_use_bowangwang":
16789
+ case "WorkBuddy_Valorant": return this.taskExpertChains(credential, task);
15951
16790
  default: return [];
15952
16791
  }
15953
16792
  }
15954
16793
  /**
16794
+ * Summon-and-use one expert NAMED BY THE TASK.
16795
+ *
16796
+ * Structurally `expertChains` minus the marketplace: the expert id is read
16797
+ * from the task's deeplink instead of by browsing `marketExpertList`, because
16798
+ * this family's experts are not listed there at all (#98 measured 500 entries
16799
+ * across both `agent` and `team`, zero hits).
16800
+ *
16801
+ * Returns `[]` — not a throw — when the task carries no usable id: the caller
16802
+ * logs "could not be built" only for exceptions, and a task shape this code
16803
+ * does not understand is information, not a fault.
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
+ }
16850
+ async taskExpertChains(credential, task) {
16851
+ const expertId = expertIdFromJumpUrl(task.jumpUrl) ?? await this.expertIdByKeyword(credential, task);
16852
+ if (expertId === void 0) {
16853
+ this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url and no marketplace match, skipping`);
16854
+ return [];
16855
+ }
16856
+ const expert = expertFromTask(expertId, task.title);
16857
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
16858
+ if (this.delayMs > 0 && !this.stopped) await sleep$1(this.delayMs);
16859
+ const chat = await this.client.openConversation(credential, expert.expertId);
16860
+ if (chat === void 0) throw new Error(`${task.taskCode}: no server conversation`);
16861
+ return [{
16862
+ transport: "desktop",
16863
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
16864
+ }];
16865
+ }
16866
+ /**
15955
16867
  * The summon-and-use chains for the expert tasks.
15956
16868
  *
15957
16869
  * Two steps per expert, and both are load-bearing: the summon events alone are
@@ -16023,21 +16935,41 @@ var WorkBuddyScheduler = class {
16023
16935
  const credential = account.credential;
16024
16936
  const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
16025
16937
  if (open.length > 0) await this.client.acceptTasks(credential, open);
16026
- 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);
16027
16940
  let credit = 0;
16028
16941
  let energy = 0;
16029
16942
  let claimed = 0;
16030
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
+ });
16031
16950
  for (const task of claimable) {
16032
16951
  if (this.stopped) break;
16033
16952
  const reward = await this.client.claimTaskReward(credential, task.taskCode);
16034
16953
  credit += reward.credit;
16035
16954
  energy += reward.energy;
16955
+ const label = task.title === "" ? task.taskCode : task.title;
16036
16956
  if (reward.credit > 0 || reward.energy > 0) {
16037
16957
  claimed++;
16038
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
+ });
16039
16965
  this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
16040
- } 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
+ }
16041
16973
  if (this.delayMs > 0 && !this.stopped) await sleep$1(this.delayMs);
16042
16974
  }
16043
16975
  return {
@@ -16045,7 +16977,8 @@ var WorkBuddyScheduler = class {
16045
16977
  credit,
16046
16978
  energy,
16047
16979
  claimableCount: claimable.length,
16048
- titles
16980
+ titles,
16981
+ items
16049
16982
  };
16050
16983
  }
16051
16984
  /**
@@ -16214,6 +17147,7 @@ const PROVIDER_NOTES_ZH = Object.freeze({
16214
17147
  "kimi_code": "Kimi Code 订阅,模型 id 取自 9router 的 kimi 注册表",
16215
17148
  "lightning": "Lightning AI 的 Model APIs 网关",
16216
17149
  "llamacpp": "本地服务器,模型列表取决于用户已加载的模型",
17150
+ "custom_openai": "自定义 OpenAI 兼容端点:vLLM / LiteLLM / one-api / 自建网关,填 base URL 和 key 后拉取模型",
16217
17151
  "llm7": "default/fast/pro 别名被固定,只留流式工具模型",
16218
17152
  "lmstudio": "本地服务器,模型列表取决于用户已加载的模型",
16219
17153
  "minimax": "推理内容从正文流中单独拆出,语音与图像 id 已剔除",
@@ -16573,6 +17507,24 @@ function parseAggregatePatch(body) {
16573
17507
  };
16574
17508
  if (typeof body["enabled"] === "boolean") patch.enabled = body["enabled"];
16575
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"];
16576
17528
  if (typeof body["baseUrl"] === "string") patch.baseUrl = body["baseUrl"];
16577
17529
  if (Array.isArray(body["models"])) {
16578
17530
  const seen = /* @__PURE__ */ new Set();
@@ -16634,7 +17586,9 @@ function aggregateStatusOf(store, models) {
16634
17586
  models: exposed,
16635
17587
  availableModels: universe,
16636
17588
  discovered: (setting.discovered?.length ?? 0) > 0,
16637
- ...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 }
16638
17592
  });
16639
17593
  }
16640
17594
  return {
@@ -16802,7 +17756,10 @@ async function poolWebStatus(deps, region = "cn") {
16802
17756
  creditReserves: deps.pool.creditReservesInOrder(),
16803
17757
  ignored: deps.ignoredAccounts?.() ?? [],
16804
17758
  catalogSource: deps.catalogs[region].currentSource(),
16805
- ...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
+ })(),
16806
17763
  ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) },
16807
17764
  ...deps.pool.skippedFilesInOrder().length === 0 ? {} : { skippedFiles: deps.pool.skippedFilesInOrder().map((entry) => ({
16808
17765
  file: basename(entry.path),
@@ -17291,33 +18248,6 @@ function registerPoolStatusRoute(ctx, deps) {
17291
18248
  }
17292
18249
  });
17293
18250
  /**
17294
- * The billion-context relay.
17295
- *
17296
- * GET only, loopback origin only. Its own route because it is the one
17297
- * document here that comes from ANOTHER plugin's loopback listener: the
17298
- * proxy publishes its own JSON and this only reads it, so folding it into
17299
- * the pool status document would put a third-party service on the critical
17300
- * path of every 30-second pool poll.
17301
- *
17302
- * Answered even when the proxy is absent — `state: 'missing'` with the
17303
- * install command is far more actionable than a 503, and it is the normal
17304
- * state on a machine that has not installed billion-context.
17305
- */
17306
- const disposeBillion = ctx.webServer.register({
17307
- kind: "exact",
17308
- path: POOL_BILLION_PATH,
17309
- handler: async (req, res) => {
17310
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
17311
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
17312
- if (deps.billionDocument === void 0) return json(res, 503, { error: "the billion-context relay is not available in this build" });
17313
- try {
17314
- json(res, 200, await deps.billionDocument());
17315
- } catch (error) {
17316
- json(res, 500, { error: safeMessage(error) });
17317
- }
17318
- }
17319
- });
17320
- /**
17321
18251
  * The chain list plus the roster its members are drawn from.
17322
18252
  *
17323
18253
  * GET only. Deliberately not part of the pool status document: chains change
@@ -17328,12 +18258,12 @@ function registerPoolStatusRoute(ctx, deps) {
17328
18258
  const disposeCombos = ctx.webServer.register({
17329
18259
  kind: "exact",
17330
18260
  path: POOL_COMBOS_PATH,
17331
- handler: (req, res) => {
18261
+ handler: async (req, res) => {
17332
18262
  if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
17333
18263
  if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
17334
18264
  if (deps.combosDocument === void 0) return json(res, 503, { error: "the routing chains are not available in this build" });
17335
18265
  try {
17336
- json(res, 200, deps.combosDocument());
18266
+ json(res, 200, await deps.combosDocument());
17337
18267
  } catch (error) {
17338
18268
  json(res, 500, { error: safeMessage(error) });
17339
18269
  }
@@ -17354,8 +18284,10 @@ function registerPoolStatusRoute(ctx, deps) {
17354
18284
  if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
17355
18285
  if (deps.saveCombos === void 0) return json(res, 503, { error: "the routing chains are not available in this build" });
17356
18286
  try {
17357
- const raw = (await readJsonBody(req))["combos"];
17358
- 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));
17359
18291
  } catch (error) {
17360
18292
  json(res, 500, { error: safeMessage(error) });
17361
18293
  }
@@ -17393,7 +18325,6 @@ function registerPoolStatusRoute(ctx, deps) {
17393
18325
  disposeRelay();
17394
18326
  disposeCombosSave();
17395
18327
  disposeCombos();
17396
- disposeBillion();
17397
18328
  disposeChannels();
17398
18329
  disposeAggregate();
17399
18330
  disposeAggregateDiscover();
@@ -17417,247 +18348,6 @@ function registerPoolStatusRoute(ctx, deps) {
17417
18348
  }, "dsh-xdpool: Web status route");
17418
18349
  }
17419
18350
  //#endregion
17420
- //#region src/billion.ts
17421
- /**
17422
- * Read-only observation of a locally running billion-context proxy.
17423
- *
17424
- * WHAT THIS IS, AND WHY IT IS NOT AN EMBEDDED COMPRESSOR
17425
- *
17426
- * billion-context is a separate DSH plugin that is very likely already
17427
- * installed on this machine — on a stock desktop profile it is mounted by the
17428
- * profile's bundle layer and runs its own loopback listener. This module never
17429
- * implements compression and never proposes to: it finds that listener and
17430
- * relays a few numbers so the card can show what the compressor is doing.
17431
- * Re-implementing folding here would mean a second, divergent answer to the
17432
- * same question and a second set of ways to get it wrong.
17433
- *
17434
- * HOW THE LISTENER IS FOUND
17435
- *
17436
- * The proxy picks an ephemeral port at spawn and records it in its own state
17437
- * directory, which we read rather than guess. Guessing would mean scanning a
17438
- * port range on every card poll, and a wrong guess is worse than no answer: it
17439
- * could read some unrelated loopback service. The recorded file is
17440
- * `<state>/billion-context/proxy-origin` and holds a JSON object with at least
17441
- * an `origin`; `port-zone.json` is the same fact keyed by lane. Both are read,
17442
- * and the origin file wins, because it is the one the proxy itself reads on
17443
- * startup.
17444
- *
17445
- * `state` is resolved the same way the proxy resolves it: `XDG_STATE_HOME` when
17446
- * set, else `<homedir>/.local/state` on every platform including Windows — not
17447
- * `%LOCALAPPDATA%`, which is what a Windows developer would reach for first and
17448
- * which would therefore be wrong.
17449
- *
17450
- * WHAT IS RELAYED
17451
- *
17452
- * Counts, sizes, timestamps. Not titles, not first-block hints, not any
17453
- * conversation material. The proxy is happy to hand over more than that, and
17454
- * taking it would put the user's own words into a document the card fetches
17455
- * every 30 seconds and that lives in the plugin's HTTP log.
17456
- *
17457
- * The one addition to that list is the proxy's own origin, so the card can link
17458
- * out to the dashboard the proxy already ships instead of redrawing it. That is
17459
- * a service address rather than conversation material, and anyone who can read
17460
- * this document can already reach that listener directly — which is why it is
17461
- * safe to publish where a title would not be.
17462
- *
17463
- * The proxy also has NO AUTHENTICATION by its own documentation — the README
17464
- * says so outright — so every request here is a hard local one, and the relay
17465
- * only ever reads `/__bili/health` and `/__bili/sessions`. Both are GETs. This
17466
- * module has no code path that could write to that proxy.
17467
- *
17468
- * @module dsh-xdpool/billion
17469
- */
17470
- /** The proxy's own management prefix. Never guess it; it is theirs. */
17471
- const BILI_PREFIX = "/__bili";
17472
- /** Loopback only. The proxy binds 127.0.0.1 and we refuse anything else. */
17473
- const LOOPBACK_HOSTS$1 = [
17474
- "127.0.0.1",
17475
- "localhost",
17476
- "[::1]",
17477
- "::1"
17478
- ];
17479
- /**
17480
- * How long one relay request may take.
17481
- *
17482
- * Short on purpose: this runs inside the card's status request, and a stalled
17483
- * third-party proxy must degrade to a panel that says "no answer" rather than
17484
- * hold the pool card hostage. The proxy answers these two endpoints in
17485
- * milliseconds; the budget is for the case where it does not.
17486
- */
17487
- const RELAY_TIMEOUT_MS = 1500;
17488
- /** How many sessions the card is shown. 50 rows is already past the fold. */
17489
- const MAX_SESSIONS = 50;
17490
- /**
17491
- * Where the proxy keeps its state directory.
17492
- *
17493
- * Mirrors the proxy's own resolution exactly: `XDG_STATE_HOME` when it holds an
17494
- * absolute path, else `<homedir>/.local/state`. Deliberately NOT
17495
- * `DSH_HOME ?? homedir()` — DSH_HOME is the profile root and a live value on a
17496
- * real desktop install, so preferring it sent this reader to
17497
- * `<profile>/.local/state/billion-context` and reported "missing" on a machine
17498
- * where the proxy was running and answering. Probed against the live proxy,
17499
- * which is the only way that mistake is visible: every unit test with a faked
17500
- * state directory passes either way.
17501
- *
17502
- * Undefined when neither resolves, because inventing a path to probe would
17503
- * turn "not installed" into a silent hunt through the filesystem.
17504
- */
17505
- function stateRoot() {
17506
- const xdg = env.XDG_STATE_HOME;
17507
- if (typeof xdg === "string" && xdg !== "") {
17508
- if (xdg.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(xdg)) return join(xdg, "billion-context");
17509
- return;
17510
- }
17511
- const home = homedir();
17512
- if (typeof home !== "string" || home === "") return void 0;
17513
- return join(home, ".local", "state", "billion-context");
17514
- }
17515
- /** A loopback http(s) origin, or undefined for anything else. */
17516
- function parseLoopbackOrigin(raw) {
17517
- let url;
17518
- try {
17519
- url = new URL(raw);
17520
- } catch {
17521
- return;
17522
- }
17523
- if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
17524
- if (!LOOPBACK_HOSTS$1.includes(url.hostname.toLowerCase())) return void 0;
17525
- return url.origin;
17526
- }
17527
- /**
17528
- * The proxy origin, from the file the proxy itself reads on startup.
17529
- *
17530
- * `port-zone.json` is the fallback because it is keyed by lane and therefore
17531
- * distinguishes the dsh proxy from, say, a concurrently running opencode one —
17532
- * but `proxy-origin` is preferred because it is the single-writer record.
17533
- */
17534
- async function readProxyOrigin() {
17535
- const root = stateRoot();
17536
- if (root === void 0) return void 0;
17537
- for (const name of ["proxy-origin", "port-zone.json"]) {
17538
- let raw;
17539
- try {
17540
- raw = await readFile(join(root, name), "utf8");
17541
- } catch {
17542
- continue;
17543
- }
17544
- if (name === "proxy-origin") {
17545
- try {
17546
- const parsed = JSON.parse(raw);
17547
- const origin = typeof parsed.origin === "string" ? parseLoopbackOrigin(parsed.origin) : void 0;
17548
- if (origin !== void 0) return origin;
17549
- } catch {}
17550
- continue;
17551
- }
17552
- try {
17553
- const port = JSON.parse(raw).lanes?.dsh;
17554
- if (typeof port === "number" && Number.isInteger(port) && port > 0 && port < 65536) return `http://127.0.0.1:${port}`;
17555
- } catch {
17556
- continue;
17557
- }
17558
- }
17559
- }
17560
- /** One GET against the proxy, with the relay budget on it. Never throws. */
17561
- async function fetchJson(origin, path) {
17562
- const controller = new AbortController();
17563
- const timer = setTimeout(() => {
17564
- controller.abort();
17565
- }, RELAY_TIMEOUT_MS);
17566
- try {
17567
- const response = await fetch(new URL(BILI_PREFIX + path, origin), {
17568
- method: "GET",
17569
- headers: { accept: "application/json" },
17570
- signal: controller.signal
17571
- });
17572
- if (!response.ok) return void 0;
17573
- return await response.json();
17574
- } catch {
17575
- return;
17576
- } finally {
17577
- clearTimeout(timer);
17578
- }
17579
- }
17580
- /** A finite number, or undefined. Guards every relayed field. */
17581
- function num(value) {
17582
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
17583
- }
17584
- /** A string, or ''. */
17585
- function str(value) {
17586
- return typeof value === "string" ? value : "";
17587
- }
17588
- /** A boolean, or false. */
17589
- function bool(value) {
17590
- return value === true;
17591
- }
17592
- /**
17593
- * One session row, reduced.
17594
- *
17595
- * Every field is defaulted rather than passed through: the proxy's document is
17596
- * a moving target across versions, and an unreadable row should render as
17597
- * zeroes we can explain, not as `undefined` reaching the card.
17598
- */
17599
- function toSession(raw) {
17600
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
17601
- const row = raw;
17602
- const id = str(row.id);
17603
- if (id === "") return void 0;
17604
- return {
17605
- id,
17606
- protocol: str(row.protocol),
17607
- live: bool(row.live),
17608
- requests: num(row.requests) ?? 0,
17609
- contextTokens: num(row.contextTokens) ?? 0,
17610
- contextTokensSource: str(row.contextTokensSource),
17611
- tokensSaved: num(row.tokensSaved) ?? 0,
17612
- inputTokens: num(row.inputTokens) ?? 0,
17613
- cachedTokens: num(row.cachedTokens) ?? 0,
17614
- outputTokens: num(row.outputTokens) ?? 0,
17615
- cacheHitPct: num(row.cacheHitPct) ?? -1,
17616
- blocks: num(row.blocks) ?? 0,
17617
- contextWindow: num(row.contextWindow) ?? 0,
17618
- lastSeen: str(row.lastSeen),
17619
- lastModel: str(row.lastModel)
17620
- };
17621
- }
17622
- /** The whole relay, safe to hand the card: it never rejects. */
17623
- async function readBillionStatus() {
17624
- const origin = await readProxyOrigin();
17625
- if (origin === void 0) return {
17626
- state: "missing",
17627
- reason: "billion-context is not running on this machine. Install it with \"dsh plugin --profile <name> add billion-context\" and this page fills in.",
17628
- sessions: [],
17629
- totalSessions: 0
17630
- };
17631
- const health = await fetchJson(origin, "/health");
17632
- if (health === null || typeof health !== "object") return {
17633
- state: "unreachable",
17634
- reason: `billion-context is recorded at ${origin} but did not answer. It may have just been restarted.`,
17635
- origin,
17636
- sessions: [],
17637
- totalSessions: 0
17638
- };
17639
- const meta = health;
17640
- const sessionsDoc = await fetchJson(origin, "/sessions");
17641
- const rawSessions = sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.sessions : void 0;
17642
- const rows = Array.isArray(rawSessions) ? rawSessions.slice(0, MAX_SESSIONS) : [];
17643
- const sessions = [];
17644
- for (const row of rows) {
17645
- const session = toSession(row);
17646
- if (session !== void 0) sessions.push(session);
17647
- }
17648
- const total = num(sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.total : void 0) ?? sessions.length;
17649
- const version = str(meta.version);
17650
- const startedAt = num(meta.startedAt);
17651
- return {
17652
- state: "ready",
17653
- origin,
17654
- ...version === "" ? {} : { version },
17655
- ...startedAt === void 0 ? {} : { startedAt },
17656
- sessions,
17657
- totalSessions: total
17658
- };
17659
- }
17660
- //#endregion
17661
18351
  //#region src/diagnostics.ts
17662
18352
  /**
17663
18353
  * A startup trace this plugin owns.
@@ -17722,6 +18412,46 @@ function recordStartupNote(line) {
17722
18412
  } catch {}
17723
18413
  return text;
17724
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
+ }
17725
18455
  /** Hard ceiling on the whole probe, so a hung upstream cannot wedge the card. */
17726
18456
  const PROBE_TIMEOUT_MS = 3e4;
17727
18457
  /**
@@ -18044,9 +18774,7 @@ async function compactWithSummary(messages, options, deps, signal) {
18044
18774
  const first = compactMessages(messages, options);
18045
18775
  if (!first.changed) return {
18046
18776
  messages: first.messages,
18047
- tokens: first.tokens,
18048
- summary: void 0,
18049
- skipped: void 0
18777
+ tokens: first.tokens
18050
18778
  };
18051
18779
  const summaryBudget = Math.max(256, Math.floor(options.budget / 4));
18052
18780
  let toSummarize = first.dropped;
@@ -18075,8 +18803,8 @@ async function compactWithSummary(messages, options, deps, signal) {
18075
18803
  }
18076
18804
  if (summary === void 0) return {
18077
18805
  messages: first.messages,
18078
- skipped,
18079
- tokens: first.tokens
18806
+ tokens: first.tokens,
18807
+ ...skipped === void 0 ? {} : { skipped }
18080
18808
  };
18081
18809
  const withSummary = injectSummary(first.messages, summary);
18082
18810
  if (estimateMessagesTokens(withSummary) > options.budget) {
@@ -18496,6 +19224,19 @@ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
18496
19224
  "localhost",
18497
19225
  "[::1]"
18498
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;
18499
19240
  /** HTTP status each upstream failure class surfaces as. */
18500
19241
  const KIND_STATUS = {
18501
19242
  hard_credit: 402,
@@ -18505,6 +19246,27 @@ const KIND_STATUS = {
18505
19246
  server: 502,
18506
19247
  client: 400
18507
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
+ };
18508
19270
  function hostnameOfHost(host) {
18509
19271
  let hostname = host.trim().toLowerCase();
18510
19272
  if (hostname.startsWith("[")) {
@@ -18663,6 +19425,34 @@ function createWorkBuddyShim(options) {
18663
19425
  const server = createServer((req, res) => {
18664
19426
  handle(req, res);
18665
19427
  });
19428
+ async function acquireForRequest(modelId, conversationKey, signal) {
19429
+ const ordinary = await pool.acquire(modelId, region, conversationKey);
19430
+ const reserveExempt = /* @__PURE__ */ new Set();
19431
+ if (ordinary !== void 0 || modelId === void 0 || signal.aborted) return {
19432
+ account: ordinary,
19433
+ reserveExempt
19434
+ };
19435
+ const now = Date.now();
19436
+ const held = pool.list(region).filter((account) => pool.isReserved(account.id) && !pool.isDisabled(account.id) && (account.credentialDeadUntilMs ?? 0) <= now && account.cooldownUntilMs <= now && (account.modelCooldowns[modelId] ?? 0) <= now);
19437
+ const pricingSignal = AbortSignal.any([signal, AbortSignal.timeout(5e3)]);
19438
+ await Promise.all(held.map(async (account) => {
19439
+ try {
19440
+ const price = (await client.fetchModels(account.credential, pricingSignal)).find((model) => model.id === modelId)?.creditMultiplier;
19441
+ logger?.info?.(`dsh-xdpool: reserve pricing model=${modelId} region=${region ?? "any"} price=${price ?? "unknown"} exempt=${!pricingSignal.aborted && price === 0}`);
19442
+ if (!pricingSignal.aborted && price === 0) reserveExempt.add(account.id);
19443
+ } catch {
19444
+ logger?.info?.(`dsh-xdpool: reserve pricing unavailable model=${modelId} region=${region ?? "any"} aborted=${pricingSignal.aborted}`);
19445
+ }
19446
+ }));
19447
+ if (signal.aborted) return {
19448
+ account: void 0,
19449
+ reserveExempt
19450
+ };
19451
+ return {
19452
+ account: await pool.acquire(modelId, region, conversationKey, reserveExempt),
19453
+ reserveExempt
19454
+ };
19455
+ }
18666
19456
  /**
18667
19457
  * "The same model has a paid row" sentence for a 429 body, or undefined.
18668
19458
  *
@@ -18954,10 +19744,22 @@ function createWorkBuddyShim(options) {
18954
19744
  }
18955
19745
  const body = withBareModelId(raw, parsed.modelId);
18956
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");
18957
19755
  try {
18958
19756
  await forwardAggregateStreamed(resolved, parsed.modelId, body, {
18959
19757
  signal,
18960
- onData: (payload) => frames.push(payload)
19758
+ onData: (payload) => frames.push(payload),
19759
+ ...usageRecorder === void 0 ? {} : { usage: {
19760
+ recorder: usageRecorder,
19761
+ turnId: aggregateTurnId
19762
+ } }
18961
19763
  });
18962
19764
  } catch (error) {
18963
19765
  if (res.headersSent) {
@@ -19158,98 +19960,249 @@ function createWorkBuddyShim(options) {
19158
19960
  }
19159
19961
  const attempts = [];
19160
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
+ };
19161
19981
  const conversationKey = conversationKeyOf(raw);
19162
19982
  for (const member of members) {
19163
19983
  if (signal.aborted) return;
19164
19984
  attempts.push(member.modelId);
19165
- if (!member.aggregate) {
19166
- const account = await pool.acquire(member.modelId, region);
19167
- if (account === void 0) {
19168
- const why = pool.unavailableReason(member.modelId, region);
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
+ }
20125
+ last = {
20126
+ kind: result.kind,
20127
+ status: result.status,
20128
+ message: result.message
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);
20134
+ continue;
20135
+ }
20136
+ if (store === void 0 || aggregate === void 0) {
19169
20137
  last = {
19170
- kind: why.reason,
19171
- status: why.reason === "cooling" ? 429 : 401,
19172
- message: `member ${member.modelId} has no usable WorkBuddy account (${why.reason})`
20138
+ kind: "combo_config",
20139
+ status: 503,
20140
+ message: "the aggregate lane is not available on this profile"
19173
20141
  };
20142
+ noteSwitch(member.modelId, "combo_config", 503);
19174
20143
  continue;
19175
20144
  }
19176
- const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
19177
- const result = await client.chatStream(account.credential, prepared, signal, conversationKey);
19178
- if (result.ok) {
19179
- const turnId = randomBytes(8).toString("hex");
19180
- await serveSuccessfulStream(res, account, result, logger, refreshBalance, pool, usageRecorder === void 0 ? void 0 : {
19181
- recorder: usageRecorder,
19182
- region: region ?? "cn",
19183
- model: member.modelId,
19184
- turnId
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
+ } }
19185
20176
  });
19186
- 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;
19187
20186
  }
19188
- last = {
19189
- kind: result.kind,
19190
- status: result.status,
19191
- message: result.message
19192
- };
19193
- if (result.kind === "soft_rate") pool.penalize(account.id, parseRateLimitReset(result.message), member.modelId);
19194
- else if (result.kind === "session_dead") pool.penalizeCredentialDead(account.id);
19195
- else if (result.kind === "hard_credit") pool.penalizeExhausted(account.id);
19196
- continue;
19197
- }
19198
- if (store === void 0 || aggregate === void 0) {
19199
- last = {
19200
- kind: "combo_config",
19201
- status: 503,
19202
- message: "the aggregate lane is not available on this profile"
19203
- };
19204
- continue;
19205
- }
19206
- const parsed = parseProviderModelId(member.modelId);
19207
- if (parsed === null) {
19208
- last = {
19209
- kind: "combo_config",
19210
- status: 400,
19211
- message: `member ${member.modelId} is not a valid provider model id`
19212
- };
19213
- continue;
19214
- }
19215
- let resolved;
19216
- try {
19217
- resolved = resolveAggregateProvider(store, parsed.providerId);
19218
- } catch (error) {
19219
- last = {
19220
- kind: "combo_config",
19221
- status: 400,
19222
- message: error instanceof Error ? error.message : String(error)
19223
- };
19224
- continue;
19225
- }
19226
- const frames = [];
19227
- try {
19228
- await forwardAggregateStreamed(resolved, parsed.modelId, withBareModelId(raw, parsed.modelId), {
19229
- signal,
19230
- 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"
19231
20193
  });
19232
- } catch (error) {
19233
- last = {
19234
- kind: error instanceof AggregateError ? error.kind : "server",
19235
- status: error instanceof AggregateError ? KIND_STATUS[error.kind] : 502,
19236
- message: error instanceof Error ? error.message : String(error)
19237
- };
19238
- 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();
19239
20200
  }
19240
- res.writeHead(200, {
19241
- "Content-Type": "text/event-stream",
19242
- "Cache-Control": "no-cache",
19243
- "Connection": "keep-alive",
19244
- "X-Accel-Buffering": "no"
19245
- });
19246
- for (const payload of frames) res.write(`data: ${payload}\n\n`);
19247
- res.write("data: [DONE]\n\n");
19248
- res.end();
19249
- return;
19250
20201
  }
19251
20202
  const kind = last?.kind ?? "combo_empty";
19252
- 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}`);
19253
20206
  }
19254
20207
  /**
19255
20208
  * Serve one chat completion, rotating accounts on rate limits.
@@ -19338,9 +20291,9 @@ function createWorkBuddyShim(options) {
19338
20291
  };
19339
20292
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
19340
20293
  if (controller.signal.aborted) return;
19341
- const account = await pool.acquire(modelId, region, conversationKey);
20294
+ const { account, reserveExempt } = await acquireForRequest(modelId, conversationKey, controller.signal);
19342
20295
  if (account === void 0) {
19343
- const why = pool.unavailableReason(modelId, region);
20296
+ const why = pool.unavailableReason(modelId, region, reserveExempt);
19344
20297
  if (why.reason === "cooling") {
19345
20298
  const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
19346
20299
  const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
@@ -19353,7 +20306,7 @@ function createWorkBuddyShim(options) {
19353
20306
  return;
19354
20307
  }
19355
20308
  if (why.reason === "reserve") {
19356
- writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
20309
+ writeOpenAIError(res, 402, "reserve_floor", `no WorkBuddy account is available; every account not excluded by a cooldown or a revoked sign-in is at or below its credit reserve (${why.total} account(s) total); lower the reserve on the pool card or top up credits`);
19357
20310
  return;
19358
20311
  }
19359
20312
  if (why.reason === "session_dead") {
@@ -19370,6 +20323,7 @@ function createWorkBuddyShim(options) {
19370
20323
  return;
19371
20324
  }
19372
20325
  usageRecorder?.record({
20326
+ channel: "workbuddy",
19373
20327
  region: region ?? "cn",
19374
20328
  model: modelId ?? "(none)",
19375
20329
  account: account.label,
@@ -19413,7 +20367,7 @@ function createWorkBuddyShim(options) {
19413
20367
  }
19414
20368
  if (isContextTooLong(last.message)) {
19415
20369
  const recovered = await recoverFromContextOverrun({
19416
- raw,
20370
+ raw: poolRaw,
19417
20371
  modelId,
19418
20372
  controller,
19419
20373
  region,
@@ -19421,8 +20375,12 @@ function createWorkBuddyShim(options) {
19421
20375
  client,
19422
20376
  pool,
19423
20377
  maxAttempts,
19424
- usage: usageContext,
19425
- contextWindow: modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow
20378
+ acquireAccount: async () => (await acquireForRequest(modelId, conversationKey, controller.signal)).account,
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
+ })()
19426
20384
  });
19427
20385
  if (recovered.ok) {
19428
20386
  await serveSuccessfulStream(res, recovered.account, recovered.result, logger, refreshBalance, pool, usageContext);
@@ -19529,6 +20487,7 @@ async function serveSuccessfulStream(res, account, result, logger, refreshBalanc
19529
20487
  recorded = true;
19530
20488
  if (usage?.recorder === void 0) return;
19531
20489
  usage.recorder.record({
20490
+ channel: "workbuddy",
19532
20491
  region: usage.region,
19533
20492
  model: usage.model,
19534
20493
  account: account.label,
@@ -19618,7 +20577,7 @@ async function recoverFromContextOverrun(options) {
19618
20577
  let compacted = messages;
19619
20578
  let compactionDetail = "";
19620
20579
  try {
19621
- const summariser = await pool.acquire(modelId, region);
20580
+ const summariser = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
19622
20581
  if (summariser === void 0) compactionDetail = "no account available to summarise with";
19623
20582
  else {
19624
20583
  const outcome = await compactWithSummary(messages, {
@@ -19652,7 +20611,7 @@ async function recoverFromContextOverrun(options) {
19652
20611
  ok: false,
19653
20612
  detail: "client disconnected"
19654
20613
  };
19655
- const account = await pool.acquire(modelId, region);
20614
+ const account = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
19656
20615
  if (account === void 0) return {
19657
20616
  ok: false,
19658
20617
  detail: "no account available after compaction"
@@ -19660,6 +20619,7 @@ async function recoverFromContextOverrun(options) {
19660
20619
  tried.push(account.label);
19661
20620
  const result = await client.chatStream(account.credential, retryBody, controller.signal, conversationKeyOf(raw));
19662
20621
  if (!result.ok) usage?.recorder.record({
20622
+ channel: "workbuddy",
19663
20623
  region: usage.region,
19664
20624
  model: usage.model,
19665
20625
  account: account.label,
@@ -20454,7 +21414,7 @@ function readPreferredIdSync(path = preferredIdPath()) {
20454
21414
  * free of documents that describe the absence of a decision.
20455
21415
  */
20456
21416
  async function writePreferredId(accountId, path = preferredIdPath()) {
20457
- await mkdir(dirname(path), { recursive: true });
21417
+ await ensureSecretDir(dirname(path));
20458
21418
  if (accountId === void 0) {
20459
21419
  await rm(path, { force: true });
20460
21420
  return;
@@ -20464,7 +21424,7 @@ async function writePreferredId(accountId, path = preferredIdPath()) {
20464
21424
  accountId: accountId.trim()
20465
21425
  }, null, 2);
20466
21426
  const temp = `${path}.tmp`;
20467
- await writeFile(temp, `${payload}\n`, "utf8");
21427
+ await writeSecretFile(temp, `${payload}\n`);
20468
21428
  await rename(temp, path);
20469
21429
  }
20470
21430
  //#endregion
@@ -20523,40 +21483,59 @@ function relaySettingsOf(store) {
20523
21483
  * itself looks like it obeyed, and the user is the only one who can tell that a
20524
21484
  * member they paid for is now a fallback to something free.
20525
21485
  */
20526
- function combosStatusOf(store, catalog, poolRows) {
21486
+ function combosStatusOf(store, catalog, poolRows, hostRows = []) {
20527
21487
  const chains = combosOf(store);
20528
21488
  const providerRows = catalog.providerRows();
20529
21489
  const settings = new Map(configuredProviderIds(store).map((providerId) => [providerId, providerSettingOf(store, providerId)]));
20530
- const candidates = [...poolRows.map((row) => ({
20531
- id: row.id,
20532
- name: row.name,
20533
- owner: "workbuddy",
20534
- usable: true,
20535
- contextWindow: row.contextWindow,
20536
- supportsImages: row.supportsImages
20537
- })), ...providerRows.map((row) => {
20538
- const setting = settings.get(row.providerId);
20539
- const provider = getProvider(row.providerId);
20540
- return {
21490
+ const candidates = [
21491
+ ...poolRows.map((row) => ({
20541
21492
  id: row.id,
20542
21493
  name: row.name,
20543
- owner: row.providerId,
20544
- usable: provider === null || setting === void 0 ? false : providerAuthUsable(provider, setting),
21494
+ owner: "workbuddy",
21495
+ usable: true,
20545
21496
  contextWindow: row.contextWindow,
20546
- supportsImages: row.supportsImages
20547
- };
20548
- })];
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
+ ];
20549
21526
  const known = new Set(candidates.map((candidate) => candidate.id));
20550
21527
  return {
20551
21528
  combos: chains.map((chain) => {
20552
21529
  const resolved = buildComboCatalog([chain], {
20553
21530
  pool: poolRows,
20554
- aggregate: providerRows
21531
+ aggregate: providerRows,
21532
+ host: hostRows
20555
21533
  })[0];
20556
21534
  return {
20557
21535
  id: resolved?.id ?? comboId(chain.name),
20558
21536
  name: chain.name,
20559
21537
  models: resolved?.models ?? [],
21538
+ platforms: splitPlatforms(resolved?.models ?? []),
20560
21539
  missing: chain.models.filter((modelId) => !known.has(modelId)),
20561
21540
  strategy: resolved?.strategy ?? chain.strategy ?? "fallback",
20562
21541
  stickyLimit: resolved?.stickyLimit ?? chain.stickyLimit ?? 1,
@@ -20745,7 +21724,18 @@ const Config = z.object({
20745
21724
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
20746
21725
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
20747
21726
  automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
20748
- sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
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."))
20749
21739
  });
20750
21740
  /** Live API, published for the CLI. */
20751
21741
  let api;
@@ -20802,6 +21792,16 @@ function warnOnPiAiGenerationMismatch(ctx) {
20802
21792
  } catch {}
20803
21793
  }
20804
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
+ /**
20805
21805
  * Start the loopback endpoint, register the `xdpool` provider, and
20806
21806
  * discover accounts. The provider registers only after `shim.ready` resolves,
20807
21807
  * because its models read the shim origin at construction time.
@@ -20904,7 +21904,8 @@ function apply(ctx, config = {}) {
20904
21904
  }
20905
21905
  };
20906
21906
  const applyConfigFromSource = () => {
20907
- 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);
20908
21909
  core.pool.applyConfig({
20909
21910
  ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
20910
21911
  ...cooldownMs === void 0 ? {} : { cooldownMs },
@@ -20923,6 +21924,7 @@ function apply(ctx, config = {}) {
20923
21924
  };
20924
21925
  core.catalogs.cn.applySelection(modelSelectionCn ?? fallbackSelection("cn"));
20925
21926
  core.catalogs.global.applySelection(modelSelectionGlobal ?? fallbackSelection("global"));
21927
+ applyLaneSelection(laneSelectionFrom(core.catalogs.cn.currentSelection().enabledModelIds, core.catalogs.global.currentSelection().enabledModelIds));
20926
21928
  core.scheduler.applyConfig(automationOptions(automation));
20927
21929
  };
20928
21930
  const settingsService = ctx.settings;
@@ -21008,6 +22010,10 @@ function apply(ctx, config = {}) {
21008
22010
  const aggregateCatalog = new AggregateCatalog(() => readAggregateSettingsSync(), () => core.catalogs.cn.current());
21009
22011
  aggregateCatalog.refresh();
21010
22012
  const freeCatalog = new FreeCatalog();
22013
+ freeLaneSwitch = (enabled) => {
22014
+ freeCatalog.setEnabled(enabled);
22015
+ };
22016
+ freeLaneSwitch(current().freeLane === true);
21011
22017
  const freeLane = createFreeLaneSource(freeCatalog, ctx.logger, usageRecorder);
21012
22018
  /**
21013
22019
  * The login channels, for the LAN gateway.
@@ -21038,6 +22044,33 @@ function apply(ctx, config = {}) {
21038
22044
  * object has no reason to carry a list it never consults.
21039
22045
  */
21040
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
+ };
21041
22074
  const shims = {
21042
22075
  cn: createWorkBuddyShim({
21043
22076
  pool: core.pool,
@@ -21134,9 +22167,7 @@ function apply(ctx, config = {}) {
21134
22167
  await setSetting("disabledAccountIds", next, next);
21135
22168
  },
21136
22169
  setCreditReserve: async (accountId, reserve) => {
21137
- const next = { ...current().creditReserves ?? {} };
21138
- if (reserve > 0) next[accountId] = reserve;
21139
- else delete next[accountId];
22170
+ const next = creditReservesWith(current().creditReserves, accountId, reserve);
21140
22171
  await setSetting("creditReserves", next, next);
21141
22172
  },
21142
22173
  setAccountIgnored: async (accountId, ignored) => {
@@ -21167,6 +22198,9 @@ function apply(ctx, config = {}) {
21167
22198
  else await updateProviderSetting(patch.providerId, {
21168
22199
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
21169
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 },
21170
22204
  ...patch.baseUrl === void 0 ? {} : { baseUrl: patch.baseUrl },
21171
22205
  ...patch.models === void 0 ? {} : { models: patch.models }
21172
22206
  });
@@ -21304,13 +22338,12 @@ function apply(ctx, config = {}) {
21304
22338
  }))
21305
22339
  };
21306
22340
  },
21307
- combosDocument: () => combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current()),
22341
+ combosDocument: async () => combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current(), await hostRoster()),
21308
22342
  saveCombos: async (raw) => {
21309
22343
  await saveCombos(raw);
21310
22344
  await aggregateCatalog.refresh();
21311
- return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current());
22345
+ return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current(), await hostRoster());
21312
22346
  },
21313
- billionDocument: readBillionStatus,
21314
22347
  /**
21315
22348
  * Relay state plus the gateway's model picker.
21316
22349
  *
@@ -21388,12 +22421,6 @@ function apply(ctx, config = {}) {
21388
22421
  adoptChannelPackAccounts(scoped, core.pool, ctx.logger);
21389
22422
  });
21390
22423
  });
21391
- mountBillionContext(ctx, ctx.logger).then((outcome) => {
21392
- recordStartupNote(`billion-context ${outcome.state}`);
21393
- if (outcome.state === "unavailable") ctx.logger.warn(`[xdpool] billion-context not mounted — ${outcome.reason}`);
21394
- }, (error) => {
21395
- recordStartupNote(`billion-context mount failed: ${describeError(error)}`);
21396
- });
21397
22424
  Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
21398
22425
  if (stopped) return;
21399
22426
  recordStartupNote(`both shims ready cn=${shims.cn.baseUrl()} global=${shims.global.baseUrl()}; entering registration`);