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