dsh-xdpool 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +158 -0
- package/README.en.md +18 -4
- package/README.md +19 -5
- package/assets/community-qr-qq.jpg +0 -0
- package/assets/community-qr-wechat.jpg +0 -0
- package/cordis.patch.yml +17 -41
- package/lib/bin.js +604 -87
- package/lib/client.js +1227 -891
- package/lib/index.d.ts +430 -143
- package/lib/index.js +2388 -1361
- package/package.json +1 -4
- package/vendor/channel-pack/pack.js +222 -16
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import crypto, { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, truncateSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { chmod, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { execFile, execFileSync } from "node:child_process";
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -15,6 +15,15 @@ import { env } from "node:process";
|
|
|
15
15
|
import http, { createServer, request } from "node:http";
|
|
16
16
|
import https, { request as request$1 } from "node:https";
|
|
17
17
|
import { Readable, Transform } from "node:stream";
|
|
18
|
+
//#region src/credit-reserve.ts
|
|
19
|
+
/** Build a merge-safe reserve update. Zero overrides inherited/previous floors. */
|
|
20
|
+
function creditReservesWith(current, accountId, reserve) {
|
|
21
|
+
return {
|
|
22
|
+
...current,
|
|
23
|
+
[accountId]: reserve
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
18
27
|
//#region src/badges.ts
|
|
19
28
|
/**
|
|
20
29
|
* Promotion badges the upstream attaches to a model.
|
|
@@ -159,24 +168,53 @@ function isAbortError(error) {
|
|
|
159
168
|
}
|
|
160
169
|
/** Cap on the reassembled compaction reply, guarding against a runaway stream. */
|
|
161
170
|
const COMPLETION_TEXT_LIMIT = 65536;
|
|
162
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Insufficient-credit markers, ASCII lowercase plus the original Chinese.
|
|
173
|
+
*
|
|
174
|
+
* The PLURAL forms are listed separately on purpose: this table is matched with
|
|
175
|
+
* `includes`, so `'credits exhausted'.includes('credit exhausted')` is false.
|
|
176
|
+
* The gateway's own 14018 message is `Credits exhausted.` — plural — which
|
|
177
|
+
* therefore matched nothing, fell through to `soft_rate`, and cooled the account
|
|
178
|
+
* and rotated instead of parking it. A rate-limit cooldown on a drained account
|
|
179
|
+
* means every model on it keeps being retried and keeps failing (#from the
|
|
180
|
+
* connect-workbuddy cross-check).
|
|
181
|
+
*/
|
|
163
182
|
const HARD_CREDIT_MARKERS = [
|
|
164
183
|
"insufficient credit",
|
|
184
|
+
"insufficient credits",
|
|
165
185
|
"no credit",
|
|
186
|
+
"no credits",
|
|
166
187
|
"credit exhausted",
|
|
188
|
+
"credits exhausted",
|
|
189
|
+
"credit exhausted.",
|
|
167
190
|
"out of credit",
|
|
191
|
+
"out of credits",
|
|
168
192
|
"quota exceeded",
|
|
169
193
|
"quota exhaust",
|
|
194
|
+
"quota exhausted",
|
|
170
195
|
"payment required",
|
|
171
196
|
"credit not enough",
|
|
197
|
+
"credits not enough",
|
|
172
198
|
"not enough credit",
|
|
199
|
+
"not enough credits",
|
|
173
200
|
"积分不足",
|
|
174
201
|
"额度不足",
|
|
175
202
|
"余额不足",
|
|
176
203
|
"积分用完",
|
|
177
204
|
"额度用尽",
|
|
178
|
-
"没有积分"
|
|
205
|
+
"没有积分",
|
|
206
|
+
"额度耗尽",
|
|
207
|
+
"积分耗尽"
|
|
179
208
|
];
|
|
209
|
+
/**
|
|
210
|
+
* Business codes that mean "this account cannot pay", independent of wording.
|
|
211
|
+
*
|
|
212
|
+
* `14018` is the gateway's credits-exhausted code; the number survived several
|
|
213
|
+
* rewordings of the sentence around it, so matching it is steadier than matching
|
|
214
|
+
* prose. Read from the body rather than from the HTTP status because the gateway
|
|
215
|
+
* answers these inside a 200 or a 4xx envelope.
|
|
216
|
+
*/
|
|
217
|
+
const HARD_CREDIT_CODES = ["14018", "14003"];
|
|
180
218
|
/** Session-invalidation markers that mean "this credential is dead; use another".
|
|
181
219
|
* Kept alongside the HTTP-status rule in `classifyUpstreamError`: the status is
|
|
182
220
|
* enough for a direct 401/403, but some failures arrive wrapped in a 200
|
|
@@ -421,7 +459,10 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
|
|
|
421
459
|
const BANNED_SYSTEM_PHRASES = Object.freeze([
|
|
422
460
|
[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"],
|
|
423
461
|
[/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"],
|
|
424
|
-
[/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"]
|
|
462
|
+
[/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"],
|
|
463
|
+
[/You are a coding agent running in the Codex CLI, a terminal-based coding assistant/gi, "You are a coding agent running in this environment, a terminal-based coding assistant"],
|
|
464
|
+
[/You are a coding agent running in the Codex CLI/gi, "You are a coding agent running in this environment"],
|
|
465
|
+
[/Codex CLI is an open source project led by OpenAI/gi, "This coding agent is an open source project"]
|
|
425
466
|
]);
|
|
426
467
|
/**
|
|
427
468
|
* Rewrite fingerprinted system/assistant content, leaving everything else
|
|
@@ -731,6 +772,7 @@ function classifyUpstreamError(status, body) {
|
|
|
731
772
|
if (status === 402) return "hard_credit";
|
|
732
773
|
if (status === 401 || status === 403) return "session_dead";
|
|
733
774
|
const lower = body.toLowerCase();
|
|
775
|
+
for (const code of HARD_CREDIT_CODES) if (body.includes(`"code":${code}`) || body.includes(`"code": ${code}`) || body.includes(`"code":"${code}"`)) return "hard_credit";
|
|
734
776
|
for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
|
|
735
777
|
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
736
778
|
if (status === 429) return "soft_rate";
|
|
@@ -1153,7 +1195,8 @@ function parseTask(value) {
|
|
|
1153
1195
|
status: typeof raw["status"] === "string" ? raw["status"] : "",
|
|
1154
1196
|
claimable: !claimed && target > 0 && current >= target,
|
|
1155
1197
|
claimed,
|
|
1156
|
-
locked: raw["locked"] === true
|
|
1198
|
+
locked: raw["locked"] === true,
|
|
1199
|
+
jumpUrl: typeof raw["jump_url"] === "string" ? raw["jump_url"] : ""
|
|
1157
1200
|
};
|
|
1158
1201
|
}
|
|
1159
1202
|
var WorkBuddyUpstreamClient = class {
|
|
@@ -2255,6 +2298,92 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2255
2298
|
}
|
|
2256
2299
|
};
|
|
2257
2300
|
//#endregion
|
|
2301
|
+
//#region src/file-perms.ts
|
|
2302
|
+
/**
|
|
2303
|
+
* Write a file that holds credentials, without leaving it world-readable.
|
|
2304
|
+
*
|
|
2305
|
+
* Every credential this plugin keeps on disk — the durable account list, the
|
|
2306
|
+
* ignore list, any mirrored balance — travelled through a plain `writeFile` with
|
|
2307
|
+
* the process umask as the only protection. On a POSIX host that typically means
|
|
2308
|
+
* `0644`: readable by every other user on the machine, and these files carry
|
|
2309
|
+
* `accessToken` / `refreshToken` verbatim.
|
|
2310
|
+
*
|
|
2311
|
+
* Two rules, and the order matters:
|
|
2312
|
+
*
|
|
2313
|
+
* 1. **Create, don't patch.** `mode` on `writeFile` only applies when the file is
|
|
2314
|
+
* created. The atomic-write pattern already writes a fresh temp file and
|
|
2315
|
+
* renames it over the target, so the temp is created with `0600` and the
|
|
2316
|
+
* rename carries that permission to the destination. Chmod'ing afterwards
|
|
2317
|
+
* would also work but leaves a window where the file exists with the old
|
|
2318
|
+
* mode.
|
|
2319
|
+
* 2. **Still chmod best-effort.** A file written by an older version already
|
|
2320
|
+
* exists with `0644`; the rename replaces the inode, so rule 1 covers it —
|
|
2321
|
+
* but a host that keeps the target in place (or a future caller that writes
|
|
2322
|
+
* directly) would not be covered, and `chmod` is cheap.
|
|
2323
|
+
*
|
|
2324
|
+
* WINDOWS: `mode` is ignored by Node on win32, and there is no ACL equivalent in
|
|
2325
|
+
* the standard library. The honest statement is what actually protects these
|
|
2326
|
+
* files there: they live under the user's own profile (%USERPROFILE%, %LOCALAPPDATA%),
|
|
2327
|
+
* whose inherited ACL excludes other standard users by default. Calling `icacls`
|
|
2328
|
+
* per write would spawn a process on every balance update to re-apply a
|
|
2329
|
+
* protection the parent directory already provides, so this module does not. A
|
|
2330
|
+
* profile relocated to a shared directory is outside what this code can detect —
|
|
2331
|
+
* the README says where the files live so the user can check.
|
|
2332
|
+
*
|
|
2333
|
+
* @module dsh-xdpool/file-perms
|
|
2334
|
+
*/
|
|
2335
|
+
/** Owner-only, for a file that holds a secret. */
|
|
2336
|
+
const SECRET_FILE_MODE = 384;
|
|
2337
|
+
/** Owner-only, for the directory that holds them. */
|
|
2338
|
+
const SECRET_DIR_MODE = 448;
|
|
2339
|
+
/**
|
|
2340
|
+
* True on a platform where POSIX modes are meaningful.
|
|
2341
|
+
*
|
|
2342
|
+
* Computed through a `typeof process` guard rather than read straight off the
|
|
2343
|
+
* global: this module is host-only in intent, but `store.ts` imports it and the
|
|
2344
|
+
* browser bundle imports `store.ts` for its row types, so the module body DOES
|
|
2345
|
+
* get bundled for the browser. A bare `process.platform` there throws
|
|
2346
|
+
* `ReferenceError: process is not defined` at first render — and it is the kind
|
|
2347
|
+
* of failure that only shows up in the packaged app.
|
|
2348
|
+
*/
|
|
2349
|
+
const POSIX = typeof process === "undefined" ? false : process.platform !== "win32";
|
|
2350
|
+
/**
|
|
2351
|
+
* Create the directory a secret lives in, owner-only.
|
|
2352
|
+
*
|
|
2353
|
+
* `recursive` so a fresh install works, and the mode applies to every directory
|
|
2354
|
+
* this call creates. An existing directory keeps its mode — `mkdir` does not
|
|
2355
|
+
* change what is already there — so `chmod` runs too, best-effort: tightening a
|
|
2356
|
+
* directory that a previous version created permissively is exactly the case
|
|
2357
|
+
* worth fixing, and a failure is not worth aborting a save over.
|
|
2358
|
+
*/
|
|
2359
|
+
async function ensureSecretDir(dir) {
|
|
2360
|
+
await mkdir(dir, {
|
|
2361
|
+
recursive: true,
|
|
2362
|
+
...POSIX ? { mode: SECRET_DIR_MODE } : {}
|
|
2363
|
+
});
|
|
2364
|
+
if (!POSIX) return;
|
|
2365
|
+
try {
|
|
2366
|
+
await chmod(dir, SECRET_DIR_MODE);
|
|
2367
|
+
} catch {}
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Write `contents` to `path` with owner-only permissions.
|
|
2371
|
+
*
|
|
2372
|
+
* Not atomic on its own — callers that need the crash-safe temp+rename dance keep
|
|
2373
|
+
* doing it and just route the temp write through here, which is why the mode is
|
|
2374
|
+
* applied at creation: the rename then carries it to the final file.
|
|
2375
|
+
*/
|
|
2376
|
+
async function writeSecretFile(path, contents) {
|
|
2377
|
+
await writeFile(path, contents, POSIX ? {
|
|
2378
|
+
encoding: "utf8",
|
|
2379
|
+
mode: SECRET_FILE_MODE
|
|
2380
|
+
} : "utf8");
|
|
2381
|
+
if (!POSIX) return;
|
|
2382
|
+
try {
|
|
2383
|
+
await chmod(path, SECRET_FILE_MODE);
|
|
2384
|
+
} catch {}
|
|
2385
|
+
}
|
|
2386
|
+
//#endregion
|
|
2258
2387
|
//#region src/ignored.ts
|
|
2259
2388
|
/**
|
|
2260
2389
|
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
@@ -2371,9 +2500,9 @@ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
|
2371
2500
|
version: 1,
|
|
2372
2501
|
accounts
|
|
2373
2502
|
}, null, 2);
|
|
2374
|
-
await
|
|
2503
|
+
await ensureSecretDir(dirname(path));
|
|
2375
2504
|
const temp = `${path}.tmp`;
|
|
2376
|
-
await
|
|
2505
|
+
await writeSecretFile(temp, `${payload}\n`);
|
|
2377
2506
|
await rename(temp, path);
|
|
2378
2507
|
}
|
|
2379
2508
|
/**
|
|
@@ -2499,9 +2628,9 @@ async function saveStoredAccounts(path, accounts) {
|
|
|
2499
2628
|
version: 1,
|
|
2500
2629
|
accounts: stored
|
|
2501
2630
|
}, null, 2) + "\n";
|
|
2502
|
-
await
|
|
2631
|
+
await ensureSecretDir(join(path, ".."));
|
|
2503
2632
|
const tmp = `${path}.tmp`;
|
|
2504
|
-
await
|
|
2633
|
+
await writeSecretFile(tmp, document);
|
|
2505
2634
|
await rename(tmp, path);
|
|
2506
2635
|
}
|
|
2507
2636
|
//#endregion
|
|
@@ -3265,16 +3394,20 @@ function parseWorkBuddyAuth(text, sourcePath, decrypt) {
|
|
|
3265
3394
|
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs$1(auth["refreshExpiresAt"]) : void 0;
|
|
3266
3395
|
if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
|
|
3267
3396
|
const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs$1(auth["lastRefreshTime"]) : void 0;
|
|
3397
|
+
const nickname = optionalString(decryptableString(identity["nickname"], decrypt).value);
|
|
3398
|
+
const uin = optionalString(identity["uin"]);
|
|
3399
|
+
const uid = optionalString(identity["uid"]);
|
|
3400
|
+
const enterpriseId = optionalString(identity["enterpriseId"]);
|
|
3268
3401
|
return {
|
|
3269
3402
|
accessToken,
|
|
3270
3403
|
refreshToken: decryptableString(auth["refreshToken"], decrypt).value,
|
|
3271
3404
|
expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs$1(auth["expiresAt"]) : 0,
|
|
3272
3405
|
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
3273
3406
|
...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
|
|
3274
|
-
...
|
|
3275
|
-
...
|
|
3276
|
-
...
|
|
3277
|
-
...
|
|
3407
|
+
...nickname === void 0 ? {} : { nickname },
|
|
3408
|
+
...uin === void 0 ? {} : { uin },
|
|
3409
|
+
...uid === void 0 ? {} : { uid },
|
|
3410
|
+
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
3278
3411
|
domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
|
|
3279
3412
|
sourcePath
|
|
3280
3413
|
};
|
|
@@ -3619,6 +3752,16 @@ var WorkBuddyAccountPool = class {
|
|
|
3619
3752
|
*/
|
|
3620
3753
|
disabledIds = /* @__PURE__ */ new Set();
|
|
3621
3754
|
/**
|
|
3755
|
+
* The account ids the store held after the last successful write.
|
|
3756
|
+
*
|
|
3757
|
+
* Read back on the next scan to say WHICH accounts left, not just how many.
|
|
3758
|
+
* The list is written wholesale, so a scan that does not find an account
|
|
3759
|
+
* deletes it — and that account's credential is then unrecoverable without a
|
|
3760
|
+
* fresh sign-in (#117). `undefined` until the first write, so the first scan
|
|
3761
|
+
* never reports every account as "leave".
|
|
3762
|
+
*/
|
|
3763
|
+
lastPersistedIds;
|
|
3764
|
+
/**
|
|
3622
3765
|
* Account ids the user threw out of the pool for good.
|
|
3623
3766
|
*
|
|
3624
3767
|
* Enforced BEFORE the credential is parsed: `scan()` skips a file whose
|
|
@@ -3695,6 +3838,11 @@ var WorkBuddyAccountPool = class {
|
|
|
3695
3838
|
* A function rather than a list because that pool is mounted asynchronously
|
|
3696
3839
|
* and can be re-read on demand; `undefined` means "no such source", which is
|
|
3697
3840
|
* the state every test and every pre-mount startup is in.
|
|
3841
|
+
*
|
|
3842
|
+
* The `| undefined` is explicit because `applyExternalSource` CLEARS the field by
|
|
3843
|
+
* assigning `undefined` — under `exactOptionalPropertyTypes` a bare `?:` means
|
|
3844
|
+
* "may be absent, never undefined", which made that documented clearing action
|
|
3845
|
+
* a type error.
|
|
3698
3846
|
*/
|
|
3699
3847
|
externalSource;
|
|
3700
3848
|
constructor(options = {}) {
|
|
@@ -3800,9 +3948,10 @@ var WorkBuddyAccountPool = class {
|
|
|
3800
3948
|
}
|
|
3801
3949
|
const byId = /* @__PURE__ */ new Map();
|
|
3802
3950
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
3803
|
-
if (this.persistent
|
|
3951
|
+
if (this.persistent) {
|
|
3804
3952
|
const stored = await loadStoredAccounts(this.storePath);
|
|
3805
3953
|
for (const entry of stored) {
|
|
3954
|
+
if (this.ignoredIds.size > 0 && this.ignoredIds.has(entry.id)) continue;
|
|
3806
3955
|
if (byId.has(entry.id)) continue;
|
|
3807
3956
|
byId.set(entry.id, {
|
|
3808
3957
|
id: entry.id,
|
|
@@ -3852,7 +4001,10 @@ var WorkBuddyAccountPool = class {
|
|
|
3852
4001
|
this.accounts = ordered;
|
|
3853
4002
|
this.lastScanAtMs = Date.now();
|
|
3854
4003
|
if (this.persistent) try {
|
|
4004
|
+
const leaving = this.lastPersistedIds === void 0 ? [] : [...this.lastPersistedIds].filter((id) => !this.accounts.some((account) => account.id === id));
|
|
4005
|
+
if (leaving.length > 0) this.logger?.warn?.(`dsh-xdpool: ${String(leaving.length)} account(s) are no longer in the pool after a rescan and were removed from the store: ${leaving.join(", ")}. Their credentials are gone unless they are signed in again.`);
|
|
3855
4006
|
await saveStoredAccounts(this.storePath, this.accounts);
|
|
4007
|
+
this.lastPersistedIds = new Set(this.accounts.map((account) => account.id));
|
|
3856
4008
|
} catch (error) {
|
|
3857
4009
|
this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
|
|
3858
4010
|
}
|
|
@@ -3872,11 +4024,11 @@ var WorkBuddyAccountPool = class {
|
|
|
3872
4024
|
* model id the legacy account-wide check applies (callers that cannot name a
|
|
3873
4025
|
* model, e.g. CLI diagnostics).
|
|
3874
4026
|
*/
|
|
3875
|
-
available(now, modelId, region) {
|
|
4027
|
+
available(now, modelId, region, reserveExempt) {
|
|
3876
4028
|
return this.accounts.filter((account) => {
|
|
3877
4029
|
if (this.disabledIds.has(account.id)) return false;
|
|
3878
4030
|
const reserve = this.creditReserves.get(account.id);
|
|
3879
|
-
if (reserve !== void 0 && reserve > 0) {
|
|
4031
|
+
if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
|
|
3880
4032
|
const balance = this.creditBalances.get(account.id);
|
|
3881
4033
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
3882
4034
|
}
|
|
@@ -3900,7 +4052,7 @@ var WorkBuddyAccountPool = class {
|
|
|
3900
4052
|
* Counts are over the region's accounts, since a provider only ever sees its
|
|
3901
4053
|
* own gateway.
|
|
3902
4054
|
*/
|
|
3903
|
-
unavailableReason(modelId, region) {
|
|
4055
|
+
unavailableReason(modelId, region, reserveExempt) {
|
|
3904
4056
|
const now = Date.now();
|
|
3905
4057
|
const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
|
|
3906
4058
|
if (inRegion.length === 0) return {
|
|
@@ -3913,6 +4065,29 @@ var WorkBuddyAccountPool = class {
|
|
|
3913
4065
|
let cooling = 0;
|
|
3914
4066
|
let disabled = 0;
|
|
3915
4067
|
let dead = 0;
|
|
4068
|
+
/**
|
|
4069
|
+
* Accounts that survived every OTHER filter and are held back only by their
|
|
4070
|
+
* credit floor.
|
|
4071
|
+
*
|
|
4072
|
+
* Counted rather than returned on the first hit. The old `return` fired the
|
|
4073
|
+
* moment it met one reserved account, so a single account parked under its
|
|
4074
|
+
* floor reported `reserve` for the whole gateway — even while four other
|
|
4075
|
+
* accounts were healthy and had just been tried. The caller turns
|
|
4076
|
+
* `reason === 'reserve'` into a 402 telling the user to lower the reserve,
|
|
4077
|
+
* which pointed at a setting that was not the problem (#98: a five-account
|
|
4078
|
+
* pool reported `reserve_floor` because of one account that had sat below
|
|
4079
|
+
* its floor for weeks, while the actual state was "the rest are cooling").
|
|
4080
|
+
*/
|
|
4081
|
+
let reserved = 0;
|
|
4082
|
+
/**
|
|
4083
|
+
* Accounts that reached the end of the walk unblocked.
|
|
4084
|
+
*
|
|
4085
|
+
* Non-zero means the pool still has somewhere to send a request, so a
|
|
4086
|
+
* reserve is NOT what this answer is about: editing a number cannot fix a
|
|
4087
|
+
* request that some other account is already able to serve, and saying
|
|
4088
|
+
* otherwise sends the reader to the wrong setting.
|
|
4089
|
+
*/
|
|
4090
|
+
let usable = 0;
|
|
3916
4091
|
for (const account of inRegion) {
|
|
3917
4092
|
if (this.disabledIds.has(account.id)) {
|
|
3918
4093
|
disabled += 1;
|
|
@@ -3928,16 +4103,14 @@ var WorkBuddyAccountPool = class {
|
|
|
3928
4103
|
continue;
|
|
3929
4104
|
}
|
|
3930
4105
|
const reserve = this.creditReserves.get(account.id);
|
|
3931
|
-
if (reserve !== void 0 && reserve > 0) {
|
|
4106
|
+
if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
|
|
3932
4107
|
const balance = this.creditBalances.get(account.id);
|
|
3933
|
-
if (balance !== void 0 && balance <= reserve)
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
dead,
|
|
3938
|
-
reason: "reserve"
|
|
3939
|
-
};
|
|
4108
|
+
if (balance !== void 0 && balance <= reserve) {
|
|
4109
|
+
reserved += 1;
|
|
4110
|
+
continue;
|
|
4111
|
+
}
|
|
3940
4112
|
}
|
|
4113
|
+
usable += 1;
|
|
3941
4114
|
}
|
|
3942
4115
|
if (dead > 0) return {
|
|
3943
4116
|
total: inRegion.length,
|
|
@@ -3960,6 +4133,13 @@ var WorkBuddyAccountPool = class {
|
|
|
3960
4133
|
dead,
|
|
3961
4134
|
reason: "disabled"
|
|
3962
4135
|
};
|
|
4136
|
+
if (usable === 0 && reserved > 0) return {
|
|
4137
|
+
total: inRegion.length,
|
|
4138
|
+
cooling,
|
|
4139
|
+
disabled,
|
|
4140
|
+
dead,
|
|
4141
|
+
reason: "reserve"
|
|
4142
|
+
};
|
|
3963
4143
|
return {
|
|
3964
4144
|
total: inRegion.length,
|
|
3965
4145
|
cooling,
|
|
@@ -4136,16 +4316,18 @@ var WorkBuddyAccountPool = class {
|
|
|
4136
4316
|
* Scans on first use, and rescans when every known account is cooling down: a
|
|
4137
4317
|
* fresh desktop login is the usual way out of an exhausted pool.
|
|
4138
4318
|
*
|
|
4319
|
+
* `reserveExempt` is host-only: account ids whose own credentials freshly
|
|
4320
|
+
* confirmed a zero-credit model. It never bypasses disabled, dead credentials, cooldowns or region.
|
|
4139
4321
|
* `conversationKey` is only consulted under `sticky`; other modes ignore it.
|
|
4140
4322
|
*/
|
|
4141
|
-
async acquire(modelId, region, conversationKey) {
|
|
4323
|
+
async acquire(modelId, region, conversationKey, reserveExempt) {
|
|
4142
4324
|
if (this.accounts.length === 0) await this.scan();
|
|
4143
4325
|
const maxTries = this.accounts.length + 1;
|
|
4144
4326
|
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
4145
|
-
let pool = this.available(Date.now(), modelId, region);
|
|
4327
|
+
let pool = this.available(Date.now(), modelId, region, reserveExempt);
|
|
4146
4328
|
if (pool.length === 0 && attempt === 0) {
|
|
4147
4329
|
await this.scan();
|
|
4148
|
-
pool = this.available(Date.now(), modelId, region);
|
|
4330
|
+
pool = this.available(Date.now(), modelId, region, reserveExempt);
|
|
4149
4331
|
}
|
|
4150
4332
|
if (pool.length === 0) return void 0;
|
|
4151
4333
|
if (this.preferredAccountId !== void 0) {
|
|
@@ -4338,6 +4520,43 @@ var WorkBuddyAccountPool = class {
|
|
|
4338
4520
|
return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
|
|
4339
4521
|
}
|
|
4340
4522
|
/**
|
|
4523
|
+
* Try to bring benched accounts back WITHOUT waiting out the cooldown.
|
|
4524
|
+
*
|
|
4525
|
+
* The cooldown is a guess about how long a problem lasts, and the guess is
|
|
4526
|
+
* usually wrong in one direction or the other: a token revoked by a password
|
|
4527
|
+
* change stays dead for the full window even after the user signs in again in
|
|
4528
|
+
* some other client, and a transient upstream hiccup benches a perfectly good
|
|
4529
|
+
* account for half an hour.
|
|
4530
|
+
*
|
|
4531
|
+
* So instead of only waiting, ask. For each benched account, one cheap
|
|
4532
|
+
* authenticated read: if it answers, the credential works and the account goes
|
|
4533
|
+
* back into rotation immediately. Nothing about the failure is trusted — only a
|
|
4534
|
+
* successful call clears it — and a probe that fails keeps the original
|
|
4535
|
+
* cooldown untouched (probing must never EXTEND a bench, or a broken endpoint
|
|
4536
|
+
* would keep every account locked out for as long as it stayed broken).
|
|
4537
|
+
*
|
|
4538
|
+
* `probe` is injected rather than imported so this class stays free of the
|
|
4539
|
+
* upstream client: the pool knows about accounts, not about HTTP.
|
|
4540
|
+
*
|
|
4541
|
+
* Returns the accounts it revived, for logging and for the run summary.
|
|
4542
|
+
*/
|
|
4543
|
+
async reviveBenched(probe) {
|
|
4544
|
+
const benched = this.deadCredentials();
|
|
4545
|
+
if (benched.length === 0) return [];
|
|
4546
|
+
const revived = [];
|
|
4547
|
+
for (const account of benched) {
|
|
4548
|
+
try {
|
|
4549
|
+
await probe(account.credential);
|
|
4550
|
+
} catch {
|
|
4551
|
+
continue;
|
|
4552
|
+
}
|
|
4553
|
+
this.clearCredentialDead(account.id);
|
|
4554
|
+
revived.push(account);
|
|
4555
|
+
this.logger?.info?.(`dsh-xdpool: ${account.label} answered a probe; back in rotation without waiting out the cooldown`);
|
|
4556
|
+
}
|
|
4557
|
+
return revived;
|
|
4558
|
+
}
|
|
4559
|
+
/**
|
|
4341
4560
|
* Refresh the account's access token when it is within the margin (or already
|
|
4342
4561
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
4343
4562
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
@@ -4484,8 +4703,23 @@ ${new Date(until).toISOString()}
|
|
|
4484
4703
|
* (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
|
|
4485
4704
|
* authoritative while being wrong.
|
|
4486
4705
|
*
|
|
4487
|
-
*
|
|
4488
|
-
*
|
|
4706
|
+
* NO PRICES AND NO PROMO BADGES HERE. This table is what the picker shows when
|
|
4707
|
+
* the startup fetch fails, and a multiplier captured months ago is a price the
|
|
4708
|
+
* user will budget against while it is quietly wrong. Two specific harms:
|
|
4709
|
+
*
|
|
4710
|
+
* - `multiplier: 0` renders the "free" badge, so a model that was free at
|
|
4711
|
+
* capture time keeps claiming to be free after the promotion ends;
|
|
4712
|
+
* - a non-zero multiplier renders next to the model, so a stale figure reads
|
|
4713
|
+
* as the current rate.
|
|
4714
|
+
*
|
|
4715
|
+
* `undefined` is the honest state: the card prints no rate and no badge, which
|
|
4716
|
+
* is exactly true — this build does not know the price. The live fetch fills
|
|
4717
|
+
* both in within a second of a working startup, and `catalogSource` already
|
|
4718
|
+
* tells the card to say the list is the built-in one.
|
|
4719
|
+
*
|
|
4720
|
+
* Windows, output ceilings and the `craft` capability tag stay: those are
|
|
4721
|
+
* properties of the model, not of a campaign, and they are what makes the list
|
|
4722
|
+
* usable offline at all.
|
|
4489
4723
|
*/
|
|
4490
4724
|
const FALLBACK_WORKBUDDY_MODELS = [
|
|
4491
4725
|
{
|
|
@@ -4502,8 +4736,7 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
4502
4736
|
contextWindow: 96e4,
|
|
4503
4737
|
maxOutputTokens: 64e3,
|
|
4504
4738
|
supportsImages: true,
|
|
4505
|
-
|
|
4506
|
-
tags: ["craft", "badge:夜间免费:#FF0000"]
|
|
4739
|
+
tags: ["craft"]
|
|
4507
4740
|
},
|
|
4508
4741
|
{
|
|
4509
4742
|
id: "hy3",
|
|
@@ -4511,40 +4744,35 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
4511
4744
|
contextWindow: 192e3,
|
|
4512
4745
|
maxOutputTokens: 64e3,
|
|
4513
4746
|
supportsImages: true,
|
|
4514
|
-
|
|
4515
|
-
tags: ["craft", "badge:限时免费:#FF0000"]
|
|
4747
|
+
tags: ["craft"]
|
|
4516
4748
|
},
|
|
4517
4749
|
{
|
|
4518
4750
|
id: "hy3-x",
|
|
4519
4751
|
name: "Hy3",
|
|
4520
4752
|
contextWindow: 192e3,
|
|
4521
4753
|
maxOutputTokens: 64e3,
|
|
4522
|
-
supportsImages: true
|
|
4523
|
-
multiplier: .05
|
|
4754
|
+
supportsImages: true
|
|
4524
4755
|
},
|
|
4525
4756
|
{
|
|
4526
4757
|
id: "space-bunny",
|
|
4527
4758
|
name: "Space-Bunny",
|
|
4528
4759
|
contextWindow: 1e6,
|
|
4529
4760
|
maxOutputTokens: 64e3,
|
|
4530
|
-
supportsImages: true
|
|
4531
|
-
multiplier: .08
|
|
4761
|
+
supportsImages: true
|
|
4532
4762
|
},
|
|
4533
4763
|
{
|
|
4534
4764
|
id: "deepseek-v4.1-flash",
|
|
4535
4765
|
name: "Deepseek-V4.1-Flash",
|
|
4536
4766
|
contextWindow: 1e6,
|
|
4537
4767
|
maxOutputTokens: 128e3,
|
|
4538
|
-
supportsImages: true
|
|
4539
|
-
multiplier: .11
|
|
4768
|
+
supportsImages: true
|
|
4540
4769
|
},
|
|
4541
4770
|
{
|
|
4542
4771
|
id: "deepseek-v4-pro",
|
|
4543
4772
|
name: "Deepseek-V4-Pro",
|
|
4544
4773
|
contextWindow: 1e6,
|
|
4545
4774
|
maxOutputTokens: 128e3,
|
|
4546
|
-
supportsImages: true
|
|
4547
|
-
multiplier: .51
|
|
4775
|
+
supportsImages: true
|
|
4548
4776
|
},
|
|
4549
4777
|
{
|
|
4550
4778
|
id: "deepseek-v4-flash",
|
|
@@ -4558,16 +4786,14 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
4558
4786
|
name: "GLM-5.3",
|
|
4559
4787
|
contextWindow: 1e6,
|
|
4560
4788
|
maxOutputTokens: 64e3,
|
|
4561
|
-
supportsImages: true
|
|
4562
|
-
multiplier: .79
|
|
4789
|
+
supportsImages: true
|
|
4563
4790
|
},
|
|
4564
4791
|
{
|
|
4565
4792
|
id: "glm-5.3-flash",
|
|
4566
4793
|
name: "GLM-5.3-Flash",
|
|
4567
4794
|
contextWindow: 1e6,
|
|
4568
4795
|
maxOutputTokens: 131072,
|
|
4569
|
-
supportsImages: true
|
|
4570
|
-
multiplier: .06
|
|
4796
|
+
supportsImages: true
|
|
4571
4797
|
},
|
|
4572
4798
|
{
|
|
4573
4799
|
id: "glm-5.2",
|
|
@@ -4575,64 +4801,56 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
4575
4801
|
contextWindow: 1e6,
|
|
4576
4802
|
maxOutputTokens: 64e3,
|
|
4577
4803
|
supportsImages: true,
|
|
4578
|
-
|
|
4579
|
-
tags: ["craft", "badge:夜间折扣:#1E90FF"]
|
|
4804
|
+
tags: ["craft"]
|
|
4580
4805
|
},
|
|
4581
4806
|
{
|
|
4582
4807
|
id: "glm-5.1",
|
|
4583
4808
|
name: "GLM-5.1",
|
|
4584
4809
|
contextWindow: 2e5,
|
|
4585
4810
|
maxOutputTokens: 48e3,
|
|
4586
|
-
supportsImages: false
|
|
4587
|
-
multiplier: .79
|
|
4811
|
+
supportsImages: false
|
|
4588
4812
|
},
|
|
4589
4813
|
{
|
|
4590
4814
|
id: "glm-5v-turbo",
|
|
4591
4815
|
name: "GLM-5v-Turbo",
|
|
4592
4816
|
contextWindow: 2e5,
|
|
4593
4817
|
maxOutputTokens: 64e3,
|
|
4594
|
-
supportsImages: true
|
|
4595
|
-
multiplier: .71
|
|
4818
|
+
supportsImages: true
|
|
4596
4819
|
},
|
|
4597
4820
|
{
|
|
4598
4821
|
id: "kimi-k3-1",
|
|
4599
4822
|
name: "Kimi-K3",
|
|
4600
4823
|
contextWindow: 1e6,
|
|
4601
4824
|
maxOutputTokens: 32e3,
|
|
4602
|
-
supportsImages: true
|
|
4603
|
-
multiplier: 1.62
|
|
4825
|
+
supportsImages: true
|
|
4604
4826
|
},
|
|
4605
4827
|
{
|
|
4606
4828
|
id: "kimi-k2.8-preview",
|
|
4607
4829
|
name: "Kimi-K2.8-Preview",
|
|
4608
4830
|
contextWindow: 1e6,
|
|
4609
4831
|
maxOutputTokens: 64e3,
|
|
4610
|
-
supportsImages: true
|
|
4611
|
-
multiplier: .77
|
|
4832
|
+
supportsImages: true
|
|
4612
4833
|
},
|
|
4613
4834
|
{
|
|
4614
4835
|
id: "kimi-k2.7",
|
|
4615
4836
|
name: "Kimi-K2.7-Code",
|
|
4616
4837
|
contextWindow: 256e3,
|
|
4617
4838
|
maxOutputTokens: 32e3,
|
|
4618
|
-
supportsImages: true
|
|
4619
|
-
multiplier: .57
|
|
4839
|
+
supportsImages: true
|
|
4620
4840
|
},
|
|
4621
4841
|
{
|
|
4622
4842
|
id: "kimi-k2.6",
|
|
4623
4843
|
name: "Kimi-K2.6",
|
|
4624
4844
|
contextWindow: 256e3,
|
|
4625
4845
|
maxOutputTokens: 32e3,
|
|
4626
|
-
supportsImages: true
|
|
4627
|
-
multiplier: .52
|
|
4846
|
+
supportsImages: true
|
|
4628
4847
|
},
|
|
4629
4848
|
{
|
|
4630
4849
|
id: "minimax-m3",
|
|
4631
4850
|
name: "MiniMax-M3",
|
|
4632
4851
|
contextWindow: 512e3,
|
|
4633
4852
|
maxOutputTokens: 64e3,
|
|
4634
|
-
supportsImages: true
|
|
4635
|
-
multiplier: .25
|
|
4853
|
+
supportsImages: true
|
|
4636
4854
|
}
|
|
4637
4855
|
];
|
|
4638
4856
|
/**
|
|
@@ -4705,11 +4923,24 @@ var WorkBuddyCatalog = class {
|
|
|
4705
4923
|
}
|
|
4706
4924
|
/**
|
|
4707
4925
|
* The models DSH should actually offer, after applying the user's selection:
|
|
4708
|
-
* disabled models are dropped, an explicit image list
|
|
4709
|
-
*
|
|
4926
|
+
* disabled models are dropped, an explicit image list decides image support,
|
|
4927
|
+
* and a per-model budget caps the advertised window.
|
|
4710
4928
|
*
|
|
4711
4929
|
* An absent `enabledModelIds` means "everything" — a fresh install with no
|
|
4712
4930
|
* saved selection must not present an empty picker.
|
|
4931
|
+
*
|
|
4932
|
+
* IMAGE SUPPORT IS THE USER'S SET, NOT AN OR. The upstream flag is `true` for
|
|
4933
|
+
* essentially every model of this vendor (measured: 15 of 16 on CN, 23 of 23
|
|
4934
|
+
* on international), so `supportsImages || imageSet.has(id)` could only ever
|
|
4935
|
+
* turn the flag ON — un-ticking the box in the card changed nothing, and the
|
|
4936
|
+
* adapter kept advertising `['text','image']`. The checkbox existed and did
|
|
4937
|
+
* not work, which is worse than not offering it.
|
|
4938
|
+
*
|
|
4939
|
+
* So when the user HAS saved an image selection, that set is the answer, in
|
|
4940
|
+
* both directions. When they have not (undefined — a fresh install, or a
|
|
4941
|
+
* config written before this fix), the upstream capability stands: silently
|
|
4942
|
+
* disabling image input for everyone who never touched the switch would be a
|
|
4943
|
+
* regression, and the absent case is exactly "no opinion".
|
|
4713
4944
|
*/
|
|
4714
4945
|
visible() {
|
|
4715
4946
|
const enabled = this.selection.enabledModelIds;
|
|
@@ -4719,7 +4950,7 @@ var WorkBuddyCatalog = class {
|
|
|
4719
4950
|
const budgets = this.selection.contextBudgets;
|
|
4720
4951
|
return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
|
|
4721
4952
|
const next = { ...model };
|
|
4722
|
-
if (imageSet !== void 0) next.supportsImages =
|
|
4953
|
+
if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
|
|
4723
4954
|
const budget = budgets?.[model.id];
|
|
4724
4955
|
if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
|
|
4725
4956
|
return next;
|
|
@@ -4998,22 +5229,6 @@ const POOL_COMBOS_SAVE_PATH = "/plugins/dsh-xdpool/combos/save";
|
|
|
4998
5229
|
*/
|
|
4999
5230
|
const POOL_RELAY_PATH = "/plugins/dsh-xdpool/relay";
|
|
5000
5231
|
/**
|
|
5001
|
-
* What the locally running billion-context proxy is doing, read-only.
|
|
5002
|
-
*
|
|
5003
|
-
* Its own route because the answer comes from ANOTHER plugin: billion-context
|
|
5004
|
-
* runs its own loopback listener and publishes its own JSON, so this route only
|
|
5005
|
-
* finds that listener and relays a filtered subset. Nothing here is folded into
|
|
5006
|
-
* {@link POOL_STATUS_PATH}: the pool card polls that every 30 seconds, and a
|
|
5007
|
-
* third-party proxy's session table is not something every pool poll should
|
|
5008
|
-
* carry.
|
|
5009
|
-
*
|
|
5010
|
-
* The word "read-only" is load-bearing on both ends. This plugin sends GETs
|
|
5011
|
-
* only — it never configures, restarts or writes to the proxy — and the payload
|
|
5012
|
-
* it relays deliberately omits every field that could carry conversation
|
|
5013
|
-
* material.
|
|
5014
|
-
*/
|
|
5015
|
-
const POOL_BILLION_PATH = "/plugins/dsh-xdpool/billion";
|
|
5016
|
-
/**
|
|
5017
5232
|
* Read the usage panel's aggregate.
|
|
5018
5233
|
*
|
|
5019
5234
|
* GET only, and token-free by construction: the recorder holds counts and
|
|
@@ -5023,6 +5238,18 @@ const POOL_BILLION_PATH = "/plugins/dsh-xdpool/billion";
|
|
|
5023
5238
|
* instead of a second one that could quietly drift.
|
|
5024
5239
|
*/
|
|
5025
5240
|
const POOL_USAGE_PATH = "/plugins/dsh-xdpool/usage";
|
|
5241
|
+
/** The five strategies, as a value, so a request body can be validated against it. */
|
|
5242
|
+
const POOL_DISTRIBUTIONS = Object.freeze([
|
|
5243
|
+
"priority",
|
|
5244
|
+
"round-robin",
|
|
5245
|
+
"balanced",
|
|
5246
|
+
"sticky",
|
|
5247
|
+
"expiry"
|
|
5248
|
+
]);
|
|
5249
|
+
/** True when `value` names one of the five documented strategies. */
|
|
5250
|
+
function isDistributionStrategy(value) {
|
|
5251
|
+
return POOL_DISTRIBUTIONS.includes(value);
|
|
5252
|
+
}
|
|
5026
5253
|
/**
|
|
5027
5254
|
* The schedule every automation job falls back to.
|
|
5028
5255
|
*
|
|
@@ -10279,6 +10506,24 @@ const PROVIDERS = Object.freeze([
|
|
|
10279
10506
|
"website": "https://openai.com/",
|
|
10280
10507
|
"local": false,
|
|
10281
10508
|
"source": "fcc"
|
|
10509
|
+
},
|
|
10510
|
+
{
|
|
10511
|
+
"id": "custom_openai",
|
|
10512
|
+
"name": "Custom OpenAI-compatible",
|
|
10513
|
+
"category": "apikey",
|
|
10514
|
+
"auth": {
|
|
10515
|
+
"kind": "apiKey",
|
|
10516
|
+
"env": "CUSTOM_OPENAI_API_KEY",
|
|
10517
|
+
"url": null
|
|
10518
|
+
},
|
|
10519
|
+
"baseUrl": "",
|
|
10520
|
+
"format": "openai-chat",
|
|
10521
|
+
"models": [],
|
|
10522
|
+
"notes": "Any OpenAI-compatible endpoint — vLLM, LiteLLM, one-api, LM Studio, a self-hosted gateway. Paste the base URL (ending in /v1) and the key on this card, then press Fetch models.",
|
|
10523
|
+
"website": null,
|
|
10524
|
+
"local": false,
|
|
10525
|
+
"baseUrlOverrideEnv": "CUSTOM_OPENAI_BASE_URL",
|
|
10526
|
+
"custom": true
|
|
10282
10527
|
}
|
|
10283
10528
|
].map((entry) => Object.freeze({
|
|
10284
10529
|
...entry,
|
|
@@ -10391,6 +10636,71 @@ const COMBO_PREFIX = "combo:";
|
|
|
10391
10636
|
const COMBO_CHANNEL = "combo";
|
|
10392
10637
|
/** Chain strategies. Anything else on disk falls back to `fallback`. */
|
|
10393
10638
|
const COMBO_STRATEGIES = ["fallback", "round-robin"];
|
|
10639
|
+
/** The owner id the pool lane uses in a platform group. */
|
|
10640
|
+
const POOL_OWNER = "workbuddy";
|
|
10641
|
+
/**
|
|
10642
|
+
* Flatten platform groups into the member ids the router walks.
|
|
10643
|
+
*
|
|
10644
|
+
* Platform order first, then model order inside each platform — the documented
|
|
10645
|
+
* semantics ("same platform: try the next model; platform exhausted: the next
|
|
10646
|
+
* platform"). A pool model is its bare id; anything else carries its owner as the
|
|
10647
|
+
* `provider/` prefix, the same composite id the aggregate catalog and the picker
|
|
10648
|
+
* use.
|
|
10649
|
+
*
|
|
10650
|
+
* Duplicates are dropped: the same pair twice would spend two attempts on one
|
|
10651
|
+
* endpoint and report a fallback that never happened.
|
|
10652
|
+
*/
|
|
10653
|
+
function flattenPlatforms(platforms) {
|
|
10654
|
+
const out = [];
|
|
10655
|
+
const seen = /* @__PURE__ */ new Set();
|
|
10656
|
+
for (const platform of platforms) {
|
|
10657
|
+
const owner = text$6(platform.owner);
|
|
10658
|
+
if (owner === "") continue;
|
|
10659
|
+
for (const raw of platform.models) {
|
|
10660
|
+
const model = text$6(raw);
|
|
10661
|
+
if (model === "" || isComboId(model)) continue;
|
|
10662
|
+
const id = owner === "workbuddy" ? model : `${owner}/${model}`;
|
|
10663
|
+
if (seen.has(id)) continue;
|
|
10664
|
+
seen.add(id);
|
|
10665
|
+
out.push(id);
|
|
10666
|
+
}
|
|
10667
|
+
}
|
|
10668
|
+
return out;
|
|
10669
|
+
}
|
|
10670
|
+
/**
|
|
10671
|
+
* Group a flat member list back into platforms.
|
|
10672
|
+
*
|
|
10673
|
+
* For a chain saved flat, so the two-level editor can still open it: each id's
|
|
10674
|
+
* owner is read from its shape (`workbuddy` for a bare id, the prefix otherwise)
|
|
10675
|
+
* and groups come out in first-seen order — which is the order the members were
|
|
10676
|
+
* in, so re-flattening reproduces exactly what was stored.
|
|
10677
|
+
*/
|
|
10678
|
+
function splitPlatforms(models) {
|
|
10679
|
+
const groups = /* @__PURE__ */ new Map();
|
|
10680
|
+
const order = [];
|
|
10681
|
+
for (const raw of models) {
|
|
10682
|
+
const id = text$6(raw);
|
|
10683
|
+
if (id === "") continue;
|
|
10684
|
+
const parsed = parseProviderModelId(id);
|
|
10685
|
+
const owner = parsed === null ? POOL_OWNER : parsed.providerId;
|
|
10686
|
+
const model = parsed === null ? id : parsed.modelId;
|
|
10687
|
+
let bucket = groups.get(owner);
|
|
10688
|
+
if (bucket === void 0) {
|
|
10689
|
+
bucket = [];
|
|
10690
|
+
groups.set(owner, bucket);
|
|
10691
|
+
order.push(owner);
|
|
10692
|
+
}
|
|
10693
|
+
if (!bucket.includes(model)) bucket.push(model);
|
|
10694
|
+
}
|
|
10695
|
+
return order.map((owner) => ({
|
|
10696
|
+
owner,
|
|
10697
|
+
models: groups.get(owner) ?? []
|
|
10698
|
+
}));
|
|
10699
|
+
}
|
|
10700
|
+
/** Windows assumed when nothing on the chain declares one. */
|
|
10701
|
+
const COMBO_DEFAULT_CONTEXT_WINDOW = 131072;
|
|
10702
|
+
/** Output ceiling assumed when nothing on the chain declares one. */
|
|
10703
|
+
const COMBO_DEFAULT_MAX_OUTPUT = 32768;
|
|
10394
10704
|
/** A trimmed non-empty string, or ''. */
|
|
10395
10705
|
function text$6(value) {
|
|
10396
10706
|
return typeof value === "string" ? value.trim() : "";
|
|
@@ -10460,7 +10770,8 @@ function normalizeCombos(raw) {
|
|
|
10460
10770
|
const name = text$6(value.name) || text$6(value.id);
|
|
10461
10771
|
if (name === "") continue;
|
|
10462
10772
|
const id = text$6(value.id);
|
|
10463
|
-
const
|
|
10773
|
+
const platforms = Array.isArray(value.platforms) ? normalizePlatforms(value.platforms) : void 0;
|
|
10774
|
+
const models = platforms === void 0 ? stringList$2(value.models).filter((model) => model !== id && !isComboId(model)) : flattenPlatforms(platforms);
|
|
10464
10775
|
if (models.length === 0) continue;
|
|
10465
10776
|
const chainId = isComboId(id) ? id : comboId(name);
|
|
10466
10777
|
if (chainId === "" || seen.has(chainId)) continue;
|
|
@@ -10471,6 +10782,7 @@ function normalizeCombos(raw) {
|
|
|
10471
10782
|
id: chainId,
|
|
10472
10783
|
name,
|
|
10473
10784
|
models,
|
|
10785
|
+
...platforms === void 0 ? {} : { platforms },
|
|
10474
10786
|
strategy,
|
|
10475
10787
|
stickyLimit: limit > 0 ? limit : 1,
|
|
10476
10788
|
enabled: value.enabled !== false
|
|
@@ -10479,6 +10791,30 @@ function normalizeCombos(raw) {
|
|
|
10479
10791
|
return out;
|
|
10480
10792
|
}
|
|
10481
10793
|
/**
|
|
10794
|
+
* Normalise the `platforms` array from an untrusted document.
|
|
10795
|
+
*
|
|
10796
|
+
* Drops a group with no owner or no models rather than keeping it: an empty group
|
|
10797
|
+
* renders as a platform card the user cannot act on, and it contributes no members,
|
|
10798
|
+
* so keeping it would make the editor disagree with the router about what the chain
|
|
10799
|
+
* contains.
|
|
10800
|
+
*/
|
|
10801
|
+
function normalizePlatforms(raw) {
|
|
10802
|
+
const out = [];
|
|
10803
|
+
for (const entry of raw) {
|
|
10804
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
10805
|
+
const value = entry;
|
|
10806
|
+
const owner = text$6(value.owner);
|
|
10807
|
+
if (owner === "") continue;
|
|
10808
|
+
const models = stringList$2(value.models).filter((model) => !isComboId(model));
|
|
10809
|
+
if (models.length === 0) continue;
|
|
10810
|
+
out.push({
|
|
10811
|
+
owner,
|
|
10812
|
+
models
|
|
10813
|
+
});
|
|
10814
|
+
}
|
|
10815
|
+
return out;
|
|
10816
|
+
}
|
|
10817
|
+
/**
|
|
10482
10818
|
* Classify one member id against both catalogs.
|
|
10483
10819
|
*
|
|
10484
10820
|
* `combo:` ids are rejected outright: a chain pointing at another chain would
|
|
@@ -10501,6 +10837,31 @@ function resolveComboMember(modelId, sources) {
|
|
|
10501
10837
|
supportsImages: aggregateRow.supportsImages
|
|
10502
10838
|
};
|
|
10503
10839
|
}
|
|
10840
|
+
/**
|
|
10841
|
+
* A 扫码登录 channel row, checked BEFORE the pool.
|
|
10842
|
+
*
|
|
10843
|
+
* Channel ids are `<channel>:<model>`, and the channel set is closed (it comes
|
|
10844
|
+
* from the pack's own registered list), so a match here is unambiguous. Order
|
|
10845
|
+
* still matters: a pool model id is bare and can never contain `:`, so trying the
|
|
10846
|
+
* channel first cannot shadow one — and doing it first keeps a channel named after
|
|
10847
|
+
* a pool id from being swallowed.
|
|
10848
|
+
*
|
|
10849
|
+
* The window and image flag are not known for a channel row (the host's listing
|
|
10850
|
+
* publishes neither), so the chain's intersection falls back to the documented
|
|
10851
|
+
* defaults. That is the honest answer: an unknown window must not be treated as a
|
|
10852
|
+
* large one, or the host would size a compaction against a limit nobody promised.
|
|
10853
|
+
*/
|
|
10854
|
+
const hostRow = sources.host?.find((row) => row.id === id);
|
|
10855
|
+
if (hostRow !== void 0) return {
|
|
10856
|
+
modelId: id,
|
|
10857
|
+
aggregate: false,
|
|
10858
|
+
host: true,
|
|
10859
|
+
channel: hostRow.channel,
|
|
10860
|
+
name: hostRow.name,
|
|
10861
|
+
contextWindow: COMBO_DEFAULT_CONTEXT_WINDOW,
|
|
10862
|
+
maxOutputTokens: COMBO_DEFAULT_MAX_OUTPUT,
|
|
10863
|
+
supportsImages: false
|
|
10864
|
+
};
|
|
10504
10865
|
const poolRow = sources.pool.find((row) => row.id === id);
|
|
10505
10866
|
if (poolRow === void 0) return void 0;
|
|
10506
10867
|
return {
|
|
@@ -10513,6 +10874,54 @@ function resolveComboMember(modelId, sources) {
|
|
|
10513
10874
|
};
|
|
10514
10875
|
}
|
|
10515
10876
|
/**
|
|
10877
|
+
* The same model served by more than one lane, in the order to try them.
|
|
10878
|
+
*
|
|
10879
|
+
* This is the "same model, different route" tier: when a lane rate-limits, the
|
|
10880
|
+
* answer is usually to reach the SAME model through another provider, not to
|
|
10881
|
+
* fall back to a different model. Switching models changes context window, tool
|
|
10882
|
+
* calling, structured-output shape and writing style — a cross-model fallback is
|
|
10883
|
+
* a last resort, not a first one.
|
|
10884
|
+
*
|
|
10885
|
+
* The registry carries 86 model ids under two or more providers (kimi-k2.5 sits
|
|
10886
|
+
* under eight), so the tier is real and not theoretical. What was missing is
|
|
10887
|
+
* that a user had to spell it out: three separate chain members naming the same
|
|
10888
|
+
* model three times, in the right order, and re-edit the chain whenever a
|
|
10889
|
+
* provider was added.
|
|
10890
|
+
*
|
|
10891
|
+
* Derivation, not configuration: for one model id, every aggregate row sharing
|
|
10892
|
+
* `modelName` (or the bare id when the registry names no shared display name) is
|
|
10893
|
+
* a route to it. Pool rows are excluded — `buddy:`/`workbuddy:` are the pool's
|
|
10894
|
+
* own lanes and naming them here would silently spend the user's WorkBuddy
|
|
10895
|
+
* credits as a "fallback".
|
|
10896
|
+
*
|
|
10897
|
+
* The requested provider, when given, comes FIRST: the user chose that lane, so
|
|
10898
|
+
* it stays the primary and the others are the alternates.
|
|
10899
|
+
*/
|
|
10900
|
+
function siblingRoutes(modelId, sources, preferredProvider) {
|
|
10901
|
+
const id = text$6(modelId);
|
|
10902
|
+
if (id === "") return [];
|
|
10903
|
+
const target = sources.aggregate.find((row) => row.id === id);
|
|
10904
|
+
if (target === void 0) return [id];
|
|
10905
|
+
const name = text$6(target.name);
|
|
10906
|
+
const bare = bareModelIdOf(target);
|
|
10907
|
+
const out = [id];
|
|
10908
|
+
for (const row of sources.aggregate) {
|
|
10909
|
+
if (row.id === id) continue;
|
|
10910
|
+
if (row.providerId === target.providerId) continue;
|
|
10911
|
+
const sameName = name !== "" && text$6(row.name) === name;
|
|
10912
|
+
const sameBare = bare !== "" && bareModelIdOf(row) === bare;
|
|
10913
|
+
if (!sameName && !sameBare) continue;
|
|
10914
|
+
if (!out.includes(row.id)) out.push(row.id);
|
|
10915
|
+
}
|
|
10916
|
+
if (preferredProvider === void 0 || out.length <= 1) return out;
|
|
10917
|
+
return [...out.filter((candidate) => candidate.startsWith(`${preferredProvider}/`)), ...out.filter((candidate) => !candidate.startsWith(`${preferredProvider}/`))];
|
|
10918
|
+
}
|
|
10919
|
+
/** The model id without its provider prefix, for cross-vendor comparison. */
|
|
10920
|
+
function bareModelIdOf(row) {
|
|
10921
|
+
const parsed = parseProviderModelId(row.id);
|
|
10922
|
+
return parsed === null ? text$6(row.modelId) : parsed.modelId;
|
|
10923
|
+
}
|
|
10924
|
+
/**
|
|
10516
10925
|
* Every combo row that can currently answer, given the catalogs.
|
|
10517
10926
|
*
|
|
10518
10927
|
* Disabled chains are skipped, as are chains whose members have all gone away:
|
|
@@ -10636,6 +11045,13 @@ function normalizeSetting(raw) {
|
|
|
10636
11045
|
if (typeof value.enabled === "boolean") setting.enabled = value.enabled;
|
|
10637
11046
|
const key = text$5(value.apiKey);
|
|
10638
11047
|
if (key !== "") setting.apiKey = key;
|
|
11048
|
+
if (Array.isArray(value.apiKeys)) {
|
|
11049
|
+
const keys = stringList$1(value.apiKeys);
|
|
11050
|
+
const [only] = keys;
|
|
11051
|
+
if (keys.length > 1) setting.apiKeys = keys;
|
|
11052
|
+
else if (only !== void 0 && setting.apiKey === void 0) setting.apiKey = only;
|
|
11053
|
+
}
|
|
11054
|
+
if (typeof value.distribution === "string" && isDistributionStrategy(value.distribution.trim())) setting.distribution = value.distribution.trim();
|
|
10639
11055
|
const baseUrl = text$5(value.baseUrl);
|
|
10640
11056
|
if (baseUrl !== "") setting.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
10641
11057
|
if (Array.isArray(value.models)) {
|
|
@@ -10756,12 +11172,12 @@ function normalizeRelayModels(raw) {
|
|
|
10756
11172
|
async function writeAggregateSettings(settings) {
|
|
10757
11173
|
const path = aggregateSettingsPath();
|
|
10758
11174
|
if (path === void 0) throw new Error("no home directory to store the aggregate settings in");
|
|
10759
|
-
await
|
|
11175
|
+
await ensureSecretDir(dirname(path));
|
|
10760
11176
|
const file = {
|
|
10761
11177
|
version: STORE_VERSION,
|
|
10762
11178
|
...settings
|
|
10763
11179
|
};
|
|
10764
|
-
await
|
|
11180
|
+
await writeSecretFile(`${path}.tmp`, `${JSON.stringify(file, null, 2)}\n`);
|
|
10765
11181
|
await rename(`${path}.tmp`, path);
|
|
10766
11182
|
}
|
|
10767
11183
|
/**
|
|
@@ -10783,16 +11199,42 @@ async function updateProviderSetting(providerId, patch) {
|
|
|
10783
11199
|
if (baseUrl === "") delete after.baseUrl;
|
|
10784
11200
|
else after.baseUrl = baseUrl;
|
|
10785
11201
|
}
|
|
10786
|
-
if (patch.
|
|
10787
|
-
const
|
|
10788
|
-
if (
|
|
10789
|
-
|
|
11202
|
+
if (patch.appendApiKey !== void 0) {
|
|
11203
|
+
const added = text$5(patch.appendApiKey);
|
|
11204
|
+
if (added !== "") {
|
|
11205
|
+
const existing = [.../* @__PURE__ */ new Set([...providerApiKeys(after)])];
|
|
11206
|
+
if (!existing.includes(added)) existing.push(added);
|
|
11207
|
+
const first = existing[0];
|
|
11208
|
+
if (first !== void 0) {
|
|
11209
|
+
after.apiKey = first;
|
|
11210
|
+
if (existing.length > 1) after.apiKeys = existing;
|
|
11211
|
+
else delete after.apiKeys;
|
|
11212
|
+
}
|
|
11213
|
+
}
|
|
11214
|
+
}
|
|
11215
|
+
if (patch.apiKeys !== void 0 || patch.apiKey !== void 0) {
|
|
11216
|
+
const incoming = patch.apiKeys !== void 0 ? patch.apiKeys : [patch.apiKey ?? ""];
|
|
11217
|
+
const keys = [...new Set(incoming.map((entry) => text$5(entry)).filter((key) => key !== ""))];
|
|
11218
|
+
const first = keys[0];
|
|
11219
|
+
if (first === void 0) {
|
|
11220
|
+
delete after.apiKey;
|
|
11221
|
+
delete after.apiKeys;
|
|
11222
|
+
} else {
|
|
11223
|
+
after.apiKey = first;
|
|
11224
|
+
if (keys.length > 1) after.apiKeys = keys;
|
|
11225
|
+
else delete after.apiKeys;
|
|
11226
|
+
}
|
|
10790
11227
|
}
|
|
10791
11228
|
if (patch.models !== void 0) {
|
|
10792
11229
|
const models = stringList$1(patch.models);
|
|
10793
11230
|
if (models.length === 0) delete after.models;
|
|
10794
11231
|
else after.models = models;
|
|
10795
11232
|
}
|
|
11233
|
+
if (patch.distribution !== void 0) {
|
|
11234
|
+
const strategy = text$5(patch.distribution);
|
|
11235
|
+
if (strategy === "") delete after.distribution;
|
|
11236
|
+
else after.distribution = strategy;
|
|
11237
|
+
}
|
|
10796
11238
|
if (patch.discovered !== void 0) {
|
|
10797
11239
|
const discovered = stringList$1(patch.discovered);
|
|
10798
11240
|
if (discovered.length === 0) delete after.discovered;
|
|
@@ -10891,6 +11333,62 @@ function providerSettingOf(store, providerId) {
|
|
|
10891
11333
|
return store.providers?.[providerId] ?? {};
|
|
10892
11334
|
}
|
|
10893
11335
|
/**
|
|
11336
|
+
* Every usable key a provider holds, preferred first.
|
|
11337
|
+
*
|
|
11338
|
+
* One reader for two spellings. A config written before multi-key support has
|
|
11339
|
+
* only `apiKey`; one written by the multi-key card has `apiKeys` with a mirrored
|
|
11340
|
+
* `apiKey`. Both must answer the same list, deduplicated, with blanks dropped — a
|
|
11341
|
+
* blank entry is a request guaranteed to 401 and, worse, would make the card
|
|
11342
|
+
* believe the provider is configured.
|
|
11343
|
+
*
|
|
11344
|
+
* Order is meaningful: entry 0 is the preferred key, the rest are alternates.
|
|
11345
|
+
*/
|
|
11346
|
+
function providerApiKeys(setting) {
|
|
11347
|
+
const list = Array.isArray(setting.apiKeys) ? setting.apiKeys : [];
|
|
11348
|
+
const out = [];
|
|
11349
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11350
|
+
for (const raw of [...list, setting.apiKey]) {
|
|
11351
|
+
const key = text$5(raw);
|
|
11352
|
+
if (key === "" || seen.has(key)) continue;
|
|
11353
|
+
seen.add(key);
|
|
11354
|
+
out.push(key);
|
|
11355
|
+
}
|
|
11356
|
+
return out;
|
|
11357
|
+
}
|
|
11358
|
+
/**
|
|
11359
|
+
* The order to TRY this provider's keys in, per the chosen strategy.
|
|
11360
|
+
*
|
|
11361
|
+
* `priority` (the default) is the stored order, which is also what the retry loop
|
|
11362
|
+
* walks: the preferred key first, then the alternates. The other four strategies
|
|
11363
|
+
* reorder the FIRST attempt only — the alternates behind it still follow in
|
|
11364
|
+
* stored order, because the point of a fallback is to reach a working key quickly,
|
|
11365
|
+
* not to shuffle the whole list on every attempt.
|
|
11366
|
+
*
|
|
11367
|
+
* Deliberately a pure function of `(keys, strategy, picker)`: the pool lane's
|
|
11368
|
+
* strategies depend on live account state, which keys do not have. `picker`
|
|
11369
|
+
* returns a float in [0,1) so a test can drive the random branches.
|
|
11370
|
+
*/
|
|
11371
|
+
function orderApiKeys(keys, strategy, picker = Math.random) {
|
|
11372
|
+
if (keys.length <= 1) return keys;
|
|
11373
|
+
const [first, ...rest] = keys;
|
|
11374
|
+
if (first === void 0) return keys;
|
|
11375
|
+
switch (strategy) {
|
|
11376
|
+
case "round-robin": {
|
|
11377
|
+
const at = Math.floor(picker() * keys.length);
|
|
11378
|
+
const index = at < 0 || at >= keys.length ? 0 : at;
|
|
11379
|
+
return [...keys.slice(index), ...keys.slice(0, index)];
|
|
11380
|
+
}
|
|
11381
|
+
case "balanced": {
|
|
11382
|
+
const at = Math.floor(picker() * keys.length);
|
|
11383
|
+
const index = at < 0 || at >= keys.length ? 0 : at;
|
|
11384
|
+
return [...keys.slice(index), ...keys.slice(0, index)];
|
|
11385
|
+
}
|
|
11386
|
+
case "expiry": return keys;
|
|
11387
|
+
case "sticky": return keys;
|
|
11388
|
+
default: return keys;
|
|
11389
|
+
}
|
|
11390
|
+
}
|
|
11391
|
+
/**
|
|
10894
11392
|
* Whether a provider could serve a model right now.
|
|
10895
11393
|
*
|
|
10896
11394
|
* Keyless and local providers are usable with nothing configured; `oauth` and
|
|
@@ -10900,7 +11398,7 @@ function providerSettingOf(store, providerId) {
|
|
|
10900
11398
|
*/
|
|
10901
11399
|
function providerAuthUsable(provider, setting) {
|
|
10902
11400
|
if (provider.auth.kind === "oauth" || provider.auth.kind === "adc") return false;
|
|
10903
|
-
if (provider.auth.kind === "apiKey") return
|
|
11401
|
+
if (provider.auth.kind === "apiKey") return providerApiKeys(setting).length > 0;
|
|
10904
11402
|
return true;
|
|
10905
11403
|
}
|
|
10906
11404
|
/**
|
|
@@ -10945,275 +11443,653 @@ function configuredProviderIds(store) {
|
|
|
10945
11443
|
return out;
|
|
10946
11444
|
}
|
|
10947
11445
|
//#endregion
|
|
10948
|
-
//#region src/
|
|
11446
|
+
//#region src/stats.ts
|
|
10949
11447
|
/**
|
|
10950
|
-
*
|
|
10951
|
-
*
|
|
10952
|
-
*
|
|
10953
|
-
* WHY A SECOND LANE BESIDE THE POOL
|
|
10954
|
-
*
|
|
10955
|
-
* The pool serves exactly one family of gateways — WorkBuddy, CN and
|
|
10956
|
-
* international — from accounts the user already signed in on the desktop app.
|
|
10957
|
-
* This module serves the orthogonal case: a provider the user holds a key for,
|
|
10958
|
-
* reached at that provider's own endpoint, with no account rotation because
|
|
10959
|
-
* there is no account to rotate. Same card, same model picker, different wire.
|
|
10960
|
-
*
|
|
10961
|
-
* WHAT IT DELIBERATELY DOES NOT DO
|
|
10962
|
-
*
|
|
10963
|
-
* It does not invent endpoints. Every URL here comes from the registry record or
|
|
10964
|
-
* from a base-URL override the user typed; a provider whose endpoint cannot be
|
|
10965
|
-
* composed is reported as such rather than being probed at a guessed URL, because
|
|
10966
|
-
* "no listing" and "provider is down" must not read the same to the user.
|
|
10967
|
-
*
|
|
10968
|
-
* It also does not decide whether a provider is USABLE — that is the settings
|
|
10969
|
-
* layer's call (`providerAuthUsable`). This module resolves what a request
|
|
10970
|
-
* needs, and refuses with a message naming what is missing.
|
|
10971
|
-
*
|
|
10972
|
-
* KEYS
|
|
10973
|
-
*
|
|
10974
|
-
* Every string that can reach a message — a failure, a body snippet, a probe's
|
|
10975
|
-
* answer — passes through {@link redactAggregateSecret} first. Providers echo
|
|
10976
|
-
* rejected credentials back (a 401 body routinely repeats the key), and this
|
|
10977
|
-
* text is folded into messages the host writes to its durable session log.
|
|
10978
|
-
*
|
|
10979
|
-
* @module dsh-xdpool/aggregate
|
|
11448
|
+
* Retained calls. Bounded on purpose: a busy week is thousands of calls, and a
|
|
11449
|
+
* dashboard that grows without limit turns into a memory leak with a chart.
|
|
10980
11450
|
*/
|
|
10981
|
-
const
|
|
10982
|
-
"openai-chat": "chat",
|
|
10983
|
-
"openai-responses": "responses",
|
|
10984
|
-
"anthropic-messages": "messages"
|
|
10985
|
-
});
|
|
11451
|
+
const USAGE_MAX_SAMPLES = 2e3;
|
|
10986
11452
|
/**
|
|
10987
|
-
*
|
|
10988
|
-
*
|
|
10989
|
-
*
|
|
11453
|
+
* Minimum total decoding time before a throughput figure is published.
|
|
11454
|
+
*
|
|
11455
|
+
* A one-token answer that arrives in 20ms would otherwise report a tps two
|
|
11456
|
+
* orders of magnitude above anything a real turn reaches, and the panel would
|
|
11457
|
+
* rank models by that artefact instead of by speed.
|
|
10990
11458
|
*/
|
|
10991
|
-
const
|
|
10992
|
-
"openai-chat": "/chat/completions",
|
|
10993
|
-
"openai-responses": "/responses",
|
|
10994
|
-
"anthropic-messages": "/messages"
|
|
10995
|
-
});
|
|
11459
|
+
const MIN_DECODE_MS = 120;
|
|
10996
11460
|
/**
|
|
10997
|
-
*
|
|
11461
|
+
* A reported token count, or null when the key is absent / not a finite
|
|
11462
|
+
* number.
|
|
10998
11463
|
*
|
|
10999
|
-
*
|
|
11000
|
-
*
|
|
11001
|
-
*
|
|
11002
|
-
* label, a wrong window is a wrong capability, so DSH would hand the provider a
|
|
11003
|
-
* prompt it cannot accept.
|
|
11464
|
+
* Zero is ACCEPTED: an empty turn really can cost nothing, and reporting that
|
|
11465
|
+
* as "the gateway said nothing" would make the panel lie in the other
|
|
11466
|
+
* direction.
|
|
11004
11467
|
*/
|
|
11005
|
-
|
|
11006
|
-
|
|
11007
|
-
/** Listing round budget. Generous for a small JSON document, short for a UI. */
|
|
11008
|
-
const LISTING_TIMEOUT_MS = 15e3;
|
|
11009
|
-
/** One turn's budget, matching the pool shim's idle ceiling. */
|
|
11010
|
-
const TURN_TIMEOUT_MS = 3e5;
|
|
11011
|
-
/** Sent when the registry does not declare one (kimchi, and most of the roster). */
|
|
11012
|
-
const USER_AGENT = "dsh-xdpool/2.1";
|
|
11013
|
-
/** How much of an upstream body is quoted back in a failure message. */
|
|
11014
|
-
const BODY_SNIPPET_CHARS = 400;
|
|
11015
|
-
/** A trimmed non-empty string, or ''. */
|
|
11016
|
-
function text$4(value) {
|
|
11017
|
-
return typeof value === "string" && value.trim() !== "" ? value.trim() : "";
|
|
11018
|
-
}
|
|
11019
|
-
/** A positive finite number, or undefined. */
|
|
11020
|
-
function number(value) {
|
|
11021
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : void 0;
|
|
11022
|
-
}
|
|
11023
|
-
function isRecord$3(value) {
|
|
11024
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11025
|
-
}
|
|
11026
|
-
/**
|
|
11027
|
-
* One aggregate-lane failure, carrying the class the caller maps onto HTTP.
|
|
11028
|
-
*
|
|
11029
|
-
* `kind` reuses the pool's vocabulary (`UpstreamErrorKind` in `upstream.ts`) so
|
|
11030
|
-
* the shim's existing error→status table serves both lanes: a third-party 429
|
|
11031
|
-
* is a `soft_rate`, a 401 is a `session_dead`, and neither needs a second table
|
|
11032
|
-
* that could drift from the first.
|
|
11033
|
-
*/
|
|
11034
|
-
var AggregateError = class extends Error {
|
|
11035
|
-
kind;
|
|
11036
|
-
status;
|
|
11037
|
-
constructor(kind, message, status = 0) {
|
|
11038
|
-
super(message);
|
|
11039
|
-
this.name = "AggregateError";
|
|
11040
|
-
this.kind = kind;
|
|
11041
|
-
this.status = status;
|
|
11042
|
-
}
|
|
11043
|
-
};
|
|
11044
|
-
/** Remove a credential from anything that may reach a message. */
|
|
11045
|
-
function redactAggregateSecret(value, apiKey) {
|
|
11046
|
-
let source = typeof value === "string" ? value : String(value ?? "");
|
|
11047
|
-
const key = text$4(apiKey);
|
|
11048
|
-
if (key === "") return source;
|
|
11049
|
-
const escaped = JSON.stringify(key).slice(1, -1);
|
|
11050
|
-
for (const needle of escaped === key ? [key] : [key, escaped]) if (source.includes(needle)) source = source.split(needle).join("[redacted]");
|
|
11051
|
-
return source;
|
|
11052
|
-
}
|
|
11053
|
-
/** A base URL a request can actually be sent to. */
|
|
11054
|
-
function hasPlaceholder(baseUrl) {
|
|
11055
|
-
return /\{[^}]*\}/.test(baseUrl);
|
|
11056
|
-
}
|
|
11057
|
-
/** The user's `baseUrl` override when present, else the registry's own. */
|
|
11058
|
-
function effectiveBaseUrl(provider, setting) {
|
|
11059
|
-
const override = text$4(setting.baseUrl);
|
|
11060
|
-
return (override !== "" ? override : text$4(provider.baseUrl)).replace(/\/+$/, "");
|
|
11061
|
-
}
|
|
11062
|
-
/**
|
|
11063
|
-
* The provider's listing URL, moved by a `baseUrl` override when — and only
|
|
11064
|
-
* when — the declared listing actually lives under the registry base.
|
|
11065
|
-
*
|
|
11066
|
-
* Three providers (cohere, deepinfra, ollama_cloud) publish their listing on
|
|
11067
|
-
* another path. Rewriting a URL that never pointed at the override would invent
|
|
11068
|
-
* one, and a guessed listing is worse than none.
|
|
11069
|
-
*/
|
|
11070
|
-
function effectiveModelsUrl(provider, setting) {
|
|
11071
|
-
const declared = modelsEndpoint(provider);
|
|
11072
|
-
if (declared === null) return null;
|
|
11073
|
-
const override = text$4(setting.baseUrl).replace(/\/+$/, "");
|
|
11074
|
-
const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
|
|
11075
|
-
if (override === "" || registryBase === "" || !declared.startsWith(registryBase)) return declared;
|
|
11076
|
-
return `${override}${declared.slice(registryBase.length)}`;
|
|
11468
|
+
function count(value) {
|
|
11469
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
11077
11470
|
}
|
|
11078
|
-
|
|
11079
|
-
|
|
11080
|
-
* endpoint (Azure without a user base URL, or a base still carrying the
|
|
11081
|
-
* registry's `{account_id}`-style placeholder).
|
|
11082
|
-
*/
|
|
11083
|
-
function effectiveChatUrl(provider, setting) {
|
|
11084
|
-
const override = text$4(setting.baseUrl).replace(/\/+$/, "");
|
|
11085
|
-
const declared = chatEndpoint(provider);
|
|
11086
|
-
if (declared !== null) {
|
|
11087
|
-
const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
|
|
11088
|
-
const url = override === "" || registryBase === "" ? declared : `${override}${declared.slice(registryBase.length)}`;
|
|
11089
|
-
return hasPlaceholder(url) ? null : url;
|
|
11090
|
-
}
|
|
11091
|
-
const suffix = CHAT_SUFFIX[provider.format];
|
|
11092
|
-
if (override === "" || suffix === void 0 || hasPlaceholder(override)) return null;
|
|
11093
|
-
return `${override}${suffix}`;
|
|
11471
|
+
function zero(value) {
|
|
11472
|
+
return count(value) ?? 0;
|
|
11094
11473
|
}
|
|
11095
|
-
|
|
11096
|
-
|
|
11097
|
-
return
|
|
11474
|
+
function detailOf(value, key) {
|
|
11475
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return 0;
|
|
11476
|
+
return zero(value[key]);
|
|
11098
11477
|
}
|
|
11099
11478
|
/**
|
|
11100
|
-
*
|
|
11101
|
-
*
|
|
11102
|
-
* Throws {@link AggregateError} with a `client` kind for every state the user
|
|
11103
|
-
* fixes in the settings page — an unknown id, a missing key, a placeholder base
|
|
11104
|
-
* URL. A `client` kind is deliberate: none of these is a transport fault, so
|
|
11105
|
-
* none of them should be retried against a different provider.
|
|
11479
|
+
* Read a gateway `usage` object, accepting both spellings OpenAI-compatible
|
|
11480
|
+
* gateways use (`prompt_tokens` / `input_tokens`).
|
|
11106
11481
|
*
|
|
11107
|
-
*
|
|
11108
|
-
*
|
|
11109
|
-
* what", and writing the answer back is a separate step the user also takes.
|
|
11110
|
-
* Gating it on the enable switch made a read-only button report
|
|
11111
|
-
* `"OpenCode Free" is not enabled in the aggregate settings` — a failure to
|
|
11112
|
-
* LOOK, dressed up as a failure to connect, on rows that carry no key field and
|
|
11113
|
-
* so offered no way to answer it. Discovery therefore does not require the
|
|
11114
|
-
* switch; publishing still does, in {@link buildAggregateCatalog} and
|
|
11115
|
-
* {@link resolveAggregateProvider} as called by the serving paths.
|
|
11482
|
+
* Returns undefined when the document is not a usage report at all, so callers
|
|
11483
|
+
* can tell "the gateway sent zeroes" from "there was nothing here".
|
|
11116
11484
|
*/
|
|
11117
|
-
function
|
|
11118
|
-
|
|
11119
|
-
const
|
|
11120
|
-
|
|
11121
|
-
const
|
|
11122
|
-
if (
|
|
11123
|
-
if (!providerAuthUsable(provider, setting)) {
|
|
11124
|
-
if (provider.auth.kind === "oauth" || provider.auth.kind === "adc") throw new AggregateError("session_dead", `"${provider.name}" signs in with ${provider.auth.kind}, not with an API key, so the aggregate lane does not serve it`);
|
|
11125
|
-
const where = text$4(provider.auth.url) === "" ? "" : ` (the provider issues keys at ${provider.auth.url})`;
|
|
11126
|
-
throw new AggregateError("client", `"${provider.name}" needs an API key: paste one in the aggregate settings${where}`);
|
|
11127
|
-
}
|
|
11128
|
-
if (options.requireEnabled !== false && !providerEnabled(provider, setting)) throw new AggregateError("client", `"${provider.name}" is not enabled in the aggregate settings`);
|
|
11129
|
-
const baseUrl = effectiveBaseUrl(provider, setting);
|
|
11130
|
-
if (baseUrl === "") throw new AggregateError("client", `"${provider.name}" has no base URL of its own — set one in the aggregate settings`);
|
|
11131
|
-
if (hasPlaceholder(baseUrl)) throw new AggregateError("client", `"${provider.name}"'s base URL still carries a registry placeholder — set the real URL in the aggregate settings`);
|
|
11132
|
-
const chatUrl = effectiveChatUrl(provider, setting);
|
|
11133
|
-
if (chatUrl === null) throw new AggregateError("client", `"${provider.name}" has no usable ${provider.format} endpoint`);
|
|
11134
|
-
const apiKey = text$4(setting.apiKey);
|
|
11135
|
-
const headers = defaultHeaders(provider, apiKey);
|
|
11136
|
-
if (headers["user-agent"] === void 0) headers["user-agent"] = USER_AGENT;
|
|
11485
|
+
function parseUsage(value) {
|
|
11486
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
11487
|
+
const obj = value;
|
|
11488
|
+
const input = count(obj["prompt_tokens"]) ?? count(obj["input_tokens"]);
|
|
11489
|
+
const output = count(obj["completion_tokens"]) ?? count(obj["output_tokens"]);
|
|
11490
|
+
if (input === null && output === null) return void 0;
|
|
11137
11491
|
return {
|
|
11138
|
-
|
|
11139
|
-
|
|
11140
|
-
|
|
11141
|
-
|
|
11142
|
-
format: provider.format,
|
|
11143
|
-
wire: WIRE_BY_FORMAT[provider.format],
|
|
11144
|
-
chatUrl,
|
|
11145
|
-
modelsUrl: effectiveModelsUrl(provider, setting),
|
|
11146
|
-
headers
|
|
11492
|
+
inputTokens: input ?? 0,
|
|
11493
|
+
outputTokens: output ?? 0,
|
|
11494
|
+
reasoningTokens: detailOf(obj["completion_tokens_details"], "reasoning_tokens"),
|
|
11495
|
+
cachedTokens: detailOf(obj["prompt_tokens_details"], "cached_tokens")
|
|
11147
11496
|
};
|
|
11148
11497
|
}
|
|
11149
11498
|
/**
|
|
11150
|
-
*
|
|
11499
|
+
* True for the trailer frame a gateway sends to report usage.
|
|
11151
11500
|
*
|
|
11152
|
-
*
|
|
11153
|
-
*
|
|
11154
|
-
*
|
|
11501
|
+
* It is recognised by an EMPTY `choices` array beside a `usage` field. A frame
|
|
11502
|
+
* that also carries content is a normal chunk that happens to mention usage,
|
|
11503
|
+
* and dropping it would delete the user's answer - which is why the strip step
|
|
11504
|
+
* downstream is allowed to remove exactly these and nothing else.
|
|
11155
11505
|
*/
|
|
11156
|
-
function
|
|
11157
|
-
|
|
11158
|
-
const
|
|
11159
|
-
|
|
11160
|
-
|
|
11161
|
-
|
|
11162
|
-
|
|
11163
|
-
|
|
11164
|
-
|
|
11165
|
-
|
|
11166
|
-
|
|
11167
|
-
|
|
11168
|
-
}
|
|
11506
|
+
function isUsageOnlyFrame(parsed) {
|
|
11507
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
|
|
11508
|
+
const obj = parsed;
|
|
11509
|
+
if (obj["usage"] === void 0 || obj["usage"] === null) return false;
|
|
11510
|
+
const choices = obj["choices"];
|
|
11511
|
+
return Array.isArray(choices) && choices.length === 0;
|
|
11512
|
+
}
|
|
11513
|
+
/** Local calendar day of an instant, as `YYYY-MM-DD`. */
|
|
11514
|
+
function usageDayKey(at) {
|
|
11515
|
+
const d = new Date(at);
|
|
11516
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
11517
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
11518
|
+
return `${d.getFullYear()}-${month}-${day}`;
|
|
11169
11519
|
}
|
|
11170
11520
|
/**
|
|
11171
|
-
*
|
|
11521
|
+
* Timing and usage captured while one stream is forwarded.
|
|
11172
11522
|
*
|
|
11173
|
-
*
|
|
11174
|
-
*
|
|
11175
|
-
* are a roster, not a choice, and treating them as one would shrink the vendor
|
|
11176
|
-
* to whatever handful the registry happens to declare.
|
|
11523
|
+
* Kept free of Node stream types so it can be unit tested with plain numbers;
|
|
11524
|
+
* the shim owns the Transform that feeds it chunks.
|
|
11177
11525
|
*/
|
|
11178
|
-
|
|
11179
|
-
|
|
11180
|
-
|
|
11181
|
-
|
|
11182
|
-
|
|
11183
|
-
|
|
11184
|
-
|
|
11185
|
-
|
|
11186
|
-
|
|
11187
|
-
|
|
11188
|
-
|
|
11189
|
-
function stringList(value) {
|
|
11190
|
-
if (!Array.isArray(value)) return [];
|
|
11191
|
-
const seen = /* @__PURE__ */ new Set();
|
|
11192
|
-
const ids = [];
|
|
11193
|
-
for (const raw of value) {
|
|
11194
|
-
const id = text$4(raw);
|
|
11195
|
-
if (id === "" || seen.has(id)) continue;
|
|
11196
|
-
seen.add(id);
|
|
11197
|
-
ids.push(id);
|
|
11526
|
+
var StreamTracker = class {
|
|
11527
|
+
/** Milliseconds from request start to the first byte, once seen. */
|
|
11528
|
+
ttftMs;
|
|
11529
|
+
/** Milliseconds from first byte to the end of the stream, once finished. */
|
|
11530
|
+
decodeMs;
|
|
11531
|
+
/** Usage seen in any frame, if the gateway sent any. */
|
|
11532
|
+
usage;
|
|
11533
|
+
firstAt;
|
|
11534
|
+
startedAt;
|
|
11535
|
+
constructor(startedAt = Date.now()) {
|
|
11536
|
+
this.startedAt = startedAt;
|
|
11198
11537
|
}
|
|
11199
|
-
|
|
11200
|
-
|
|
11538
|
+
/** Record the first byte. Repeated calls keep the earliest. */
|
|
11539
|
+
markFirstByte(at = Date.now()) {
|
|
11540
|
+
if (this.firstAt !== void 0) return;
|
|
11541
|
+
this.firstAt = at;
|
|
11542
|
+
const ttft = at - this.startedAt;
|
|
11543
|
+
if (ttft >= 0) this.ttftMs = ttft;
|
|
11544
|
+
}
|
|
11545
|
+
/** Record stream completion. Repeated calls keep the earliest. */
|
|
11546
|
+
finish(at = Date.now()) {
|
|
11547
|
+
if (this.decodeMs !== void 0) return;
|
|
11548
|
+
if (this.firstAt === void 0) return;
|
|
11549
|
+
const decode = at - this.firstAt;
|
|
11550
|
+
if (decode >= 0) this.decodeMs = decode;
|
|
11551
|
+
}
|
|
11552
|
+
/** Fold a parsed frame's usage in; later frames overwrite, as the gateway intends. */
|
|
11553
|
+
observe(parsed) {
|
|
11554
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
|
|
11555
|
+
const usage = parseUsage(parsed["usage"]);
|
|
11556
|
+
if (usage !== void 0) this.usage = usage;
|
|
11557
|
+
}
|
|
11558
|
+
};
|
|
11201
11559
|
/**
|
|
11202
|
-
*
|
|
11560
|
+
* A bounded window of recent calls, summarised on demand.
|
|
11203
11561
|
*
|
|
11204
|
-
*
|
|
11205
|
-
*
|
|
11206
|
-
*
|
|
11207
|
-
*
|
|
11562
|
+
* In-memory by design: this is a live panel, and persisting every prompt's
|
|
11563
|
+
* token counts would turn a convenience into a record of what the user asked.
|
|
11564
|
+
* A restart therefore starts the chart again, which the panel states rather
|
|
11565
|
+
* than hides.
|
|
11208
11566
|
*/
|
|
11209
|
-
|
|
11210
|
-
|
|
11211
|
-
|
|
11212
|
-
|
|
11213
|
-
|
|
11214
|
-
|
|
11215
|
-
|
|
11216
|
-
|
|
11567
|
+
var UsageRecorder = class {
|
|
11568
|
+
samples = [];
|
|
11569
|
+
limit;
|
|
11570
|
+
constructor(limit = USAGE_MAX_SAMPLES) {
|
|
11571
|
+
this.limit = limit > 0 ? limit : 1;
|
|
11572
|
+
}
|
|
11573
|
+
/** Retain one call, dropping the oldest once the window is full. */
|
|
11574
|
+
record(call) {
|
|
11575
|
+
this.samples.push(call);
|
|
11576
|
+
while (this.samples.length > this.limit) this.samples.shift();
|
|
11577
|
+
}
|
|
11578
|
+
/** Samples currently retained. */
|
|
11579
|
+
size() {
|
|
11580
|
+
return this.samples.length;
|
|
11581
|
+
}
|
|
11582
|
+
/** Forget every retained call. */
|
|
11583
|
+
reset() {
|
|
11584
|
+
this.samples = [];
|
|
11585
|
+
}
|
|
11586
|
+
/** Aggregate the retained window. */
|
|
11587
|
+
summary() {
|
|
11588
|
+
const rows = /* @__PURE__ */ new Map();
|
|
11589
|
+
const turns = /* @__PURE__ */ new Map();
|
|
11590
|
+
let calls = 0;
|
|
11591
|
+
let usageReported = false;
|
|
11592
|
+
for (const call of this.samples) {
|
|
11593
|
+
calls += 1;
|
|
11594
|
+
if (call.usage !== void 0) usageReported = true;
|
|
11595
|
+
const bucket = turns.get(call.turnId) ?? {
|
|
11596
|
+
calls: 0,
|
|
11597
|
+
succeeded: 0,
|
|
11598
|
+
failed: 0
|
|
11599
|
+
};
|
|
11600
|
+
bucket.calls += 1;
|
|
11601
|
+
if (call.ok) bucket.succeeded += 1;
|
|
11602
|
+
else bucket.failed += 1;
|
|
11603
|
+
turns.set(call.turnId, bucket);
|
|
11604
|
+
const key = `${call.channel}\u0000${call.region}\u0000${call.model}`;
|
|
11605
|
+
let acc = rows.get(key);
|
|
11606
|
+
if (acc === void 0) {
|
|
11607
|
+
acc = {
|
|
11608
|
+
row: {
|
|
11609
|
+
channel: call.channel,
|
|
11610
|
+
region: call.region,
|
|
11611
|
+
model: call.model,
|
|
11612
|
+
calls: 0,
|
|
11613
|
+
turns: 0,
|
|
11614
|
+
failed: 0,
|
|
11615
|
+
recovered: 0,
|
|
11616
|
+
inputTokens: 0,
|
|
11617
|
+
outputTokens: 0,
|
|
11618
|
+
reasoningTokens: 0,
|
|
11619
|
+
cachedTokens: 0,
|
|
11620
|
+
tps: null,
|
|
11621
|
+
avgTtftMs: null,
|
|
11622
|
+
lastUsedAt: 0
|
|
11623
|
+
},
|
|
11624
|
+
turnIds: /* @__PURE__ */ new Set(),
|
|
11625
|
+
decodeMs: 0,
|
|
11626
|
+
ttftTotal: 0,
|
|
11627
|
+
ttftCount: 0
|
|
11628
|
+
};
|
|
11629
|
+
rows.set(key, acc);
|
|
11630
|
+
}
|
|
11631
|
+
acc.row.calls += 1;
|
|
11632
|
+
acc.turnIds.add(call.turnId);
|
|
11633
|
+
if (!call.ok) acc.row.failed += 1;
|
|
11634
|
+
if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
|
|
11635
|
+
if (call.ttftMs !== void 0) {
|
|
11636
|
+
acc.ttftTotal += call.ttftMs;
|
|
11637
|
+
acc.ttftCount += 1;
|
|
11638
|
+
}
|
|
11639
|
+
if (call.decodeMs !== void 0) acc.decodeMs += call.decodeMs;
|
|
11640
|
+
if (call.usage !== void 0) {
|
|
11641
|
+
acc.row.inputTokens += call.usage.inputTokens;
|
|
11642
|
+
acc.row.outputTokens += call.usage.outputTokens;
|
|
11643
|
+
acc.row.reasoningTokens += call.usage.reasoningTokens;
|
|
11644
|
+
acc.row.cachedTokens += call.usage.cachedTokens;
|
|
11645
|
+
}
|
|
11646
|
+
}
|
|
11647
|
+
const failedTurnIds = /* @__PURE__ */ new Set();
|
|
11648
|
+
let recoveredTurns = 0;
|
|
11649
|
+
for (const [turnId, bucket] of turns) if (bucket.failed > 0 && bucket.succeeded === 0) failedTurnIds.add(turnId);
|
|
11650
|
+
else if (bucket.failed > 0) recoveredTurns += 1;
|
|
11651
|
+
const out = [];
|
|
11652
|
+
for (const acc of rows.values()) {
|
|
11653
|
+
let recovered = 0;
|
|
11654
|
+
for (const turnId of acc.turnIds) {
|
|
11655
|
+
const bucket = turns.get(turnId);
|
|
11656
|
+
if (bucket !== void 0 && bucket.failed > 0 && bucket.succeeded > 0) recovered += 1;
|
|
11657
|
+
}
|
|
11658
|
+
out.push({
|
|
11659
|
+
...acc.row,
|
|
11660
|
+
turns: acc.turnIds.size,
|
|
11661
|
+
recovered,
|
|
11662
|
+
tps: acc.decodeMs >= MIN_DECODE_MS ? Math.round(acc.row.outputTokens / (acc.decodeMs / 1e3)) : null,
|
|
11663
|
+
avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftTotal / acc.ttftCount) : null
|
|
11664
|
+
});
|
|
11665
|
+
}
|
|
11666
|
+
out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.model.localeCompare(b.model));
|
|
11667
|
+
return {
|
|
11668
|
+
samples: this.samples.length,
|
|
11669
|
+
turns: turns.size,
|
|
11670
|
+
failedTurns: failedTurnIds.size,
|
|
11671
|
+
recoveredTurns,
|
|
11672
|
+
calls,
|
|
11673
|
+
usageReported,
|
|
11674
|
+
entries: this.samples.slice().reverse(),
|
|
11675
|
+
rows: out,
|
|
11676
|
+
accounts: this.accountRows(),
|
|
11677
|
+
days: this.days(failedTurnIds)
|
|
11678
|
+
};
|
|
11679
|
+
}
|
|
11680
|
+
/**
|
|
11681
|
+
* One row per account, busiest first.
|
|
11682
|
+
*
|
|
11683
|
+
* Samples that never named an account are grouped under an empty label
|
|
11684
|
+
* rather than dropped: `account` is optional on `UsageCall`, so a caller that
|
|
11685
|
+
* files a sample without one - a failure raised before any account was ever
|
|
11686
|
+
* picked, say - would otherwise vanish from this view, and the account table
|
|
11687
|
+
* would add up to less than the call total with nothing on screen to explain
|
|
11688
|
+
* the gap. Every shim record site names its account; this branch exists so
|
|
11689
|
+
* that a future one cannot quietly break the totals.
|
|
11690
|
+
*
|
|
11691
|
+
* `topModels` keeps the two most-used ids rather than every one: this column
|
|
11692
|
+
* answers "what does this account usually run", and a list of nine ids is not
|
|
11693
|
+
* an answer to that question, it is just the other table again.
|
|
11694
|
+
*/
|
|
11695
|
+
accountRows() {
|
|
11696
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
11697
|
+
for (const call of this.samples) {
|
|
11698
|
+
const label = call.account ?? "";
|
|
11699
|
+
const key = `${call.channel}\u0000${call.region}\u0000${label}`;
|
|
11700
|
+
let acc = buckets.get(key);
|
|
11701
|
+
if (acc === void 0) {
|
|
11702
|
+
acc = {
|
|
11703
|
+
row: {
|
|
11704
|
+
account: label,
|
|
11705
|
+
channel: call.channel,
|
|
11706
|
+
region: call.region,
|
|
11707
|
+
calls: 0,
|
|
11708
|
+
turns: 0,
|
|
11709
|
+
failed: 0,
|
|
11710
|
+
inputTokens: 0,
|
|
11711
|
+
outputTokens: 0,
|
|
11712
|
+
cachedTokens: 0,
|
|
11713
|
+
lastUsedAt: 0,
|
|
11714
|
+
topModels: []
|
|
11715
|
+
},
|
|
11716
|
+
turnIds: /* @__PURE__ */ new Set(),
|
|
11717
|
+
modelCalls: /* @__PURE__ */ new Map()
|
|
11718
|
+
};
|
|
11719
|
+
buckets.set(key, acc);
|
|
11720
|
+
}
|
|
11721
|
+
acc.row.calls += 1;
|
|
11722
|
+
acc.turnIds.add(call.turnId);
|
|
11723
|
+
if (!call.ok) acc.row.failed += 1;
|
|
11724
|
+
if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
|
|
11725
|
+
acc.modelCalls.set(call.model, (acc.modelCalls.get(call.model) ?? 0) + 1);
|
|
11726
|
+
if (call.usage !== void 0) {
|
|
11727
|
+
acc.row.inputTokens += call.usage.inputTokens;
|
|
11728
|
+
acc.row.outputTokens += call.usage.outputTokens;
|
|
11729
|
+
acc.row.cachedTokens += call.usage.cachedTokens;
|
|
11730
|
+
}
|
|
11731
|
+
}
|
|
11732
|
+
const out = [];
|
|
11733
|
+
for (const acc of buckets.values()) {
|
|
11734
|
+
const topModels = [...acc.modelCalls.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 2).map(([model]) => model);
|
|
11735
|
+
out.push({
|
|
11736
|
+
...acc.row,
|
|
11737
|
+
turns: acc.turnIds.size,
|
|
11738
|
+
topModels
|
|
11739
|
+
});
|
|
11740
|
+
}
|
|
11741
|
+
out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.account.localeCompare(b.account));
|
|
11742
|
+
return out;
|
|
11743
|
+
}
|
|
11744
|
+
/**
|
|
11745
|
+
* The daily series, oldest first, limited to the most recent days.
|
|
11746
|
+
*
|
|
11747
|
+
* Sorted by key rather than insertion order because a retained window can
|
|
11748
|
+
* straddle a month boundary, and a chart whose axis jumps backwards reads as
|
|
11749
|
+
* corrupted data rather than as history.
|
|
11750
|
+
*
|
|
11751
|
+
* `failedTurns` is counted by DISTINCT turn id, not by summing per-call
|
|
11752
|
+
* failures: a turn that rotated three accounts and failed on all three is
|
|
11753
|
+
* still one failed turn, and the whole reason this module keeps turns and
|
|
11754
|
+
* calls apart is so that number stays honest in every place it appears.
|
|
11755
|
+
*/
|
|
11756
|
+
days(failedTurnIds) {
|
|
11757
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
11758
|
+
for (const call of this.samples) {
|
|
11759
|
+
const key = usageDayKey(call.at);
|
|
11760
|
+
let acc = buckets.get(key);
|
|
11761
|
+
if (acc === void 0) {
|
|
11762
|
+
acc = {
|
|
11763
|
+
day: key,
|
|
11764
|
+
calls: 0,
|
|
11765
|
+
inputTokens: 0,
|
|
11766
|
+
outputTokens: 0,
|
|
11767
|
+
failedTurnIds: /* @__PURE__ */ new Set()
|
|
11768
|
+
};
|
|
11769
|
+
buckets.set(key, acc);
|
|
11770
|
+
}
|
|
11771
|
+
acc.calls += 1;
|
|
11772
|
+
acc.inputTokens += call.usage?.inputTokens ?? 0;
|
|
11773
|
+
acc.outputTokens += call.usage?.outputTokens ?? 0;
|
|
11774
|
+
if (failedTurnIds.has(call.turnId)) acc.failedTurnIds.add(call.turnId);
|
|
11775
|
+
}
|
|
11776
|
+
return [...buckets.values()].sort((a, b) => a.day.localeCompare(b.day)).slice(-14).map((acc) => ({
|
|
11777
|
+
day: acc.day,
|
|
11778
|
+
calls: acc.calls,
|
|
11779
|
+
failedTurns: acc.failedTurnIds.size,
|
|
11780
|
+
inputTokens: acc.inputTokens,
|
|
11781
|
+
outputTokens: acc.outputTokens
|
|
11782
|
+
}));
|
|
11783
|
+
}
|
|
11784
|
+
};
|
|
11785
|
+
/**
|
|
11786
|
+
* Inspect one SSE frame's content, without its trailing separator.
|
|
11787
|
+
*
|
|
11788
|
+
* Returns undefined when no `data:` line parsed, which covers the `[DONE]`
|
|
11789
|
+
* sentinel, keep-alive comments and truncated JSON alike - a frame the forwarder
|
|
11790
|
+
* has no opinion about must come back untouched rather than guessed at.
|
|
11791
|
+
*/
|
|
11792
|
+
function inspectSseFrame(frame) {
|
|
11793
|
+
for (const line of frame.split("\n")) {
|
|
11794
|
+
const trimmed = line.trim();
|
|
11795
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
11796
|
+
const payload = trimmed.slice(5).trim();
|
|
11797
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
11798
|
+
let parsed;
|
|
11799
|
+
try {
|
|
11800
|
+
parsed = JSON.parse(payload);
|
|
11801
|
+
} catch {
|
|
11802
|
+
continue;
|
|
11803
|
+
}
|
|
11804
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
|
|
11805
|
+
const usage = parsed["usage"];
|
|
11806
|
+
if (usage === void 0 || usage === null) continue;
|
|
11807
|
+
return {
|
|
11808
|
+
parsed,
|
|
11809
|
+
usageOnly: isUsageOnlyFrame(parsed)
|
|
11810
|
+
};
|
|
11811
|
+
}
|
|
11812
|
+
}
|
|
11813
|
+
//#endregion
|
|
11814
|
+
//#region src/aggregate.ts
|
|
11815
|
+
/**
|
|
11816
|
+
* Aggregate lane: the 76 declared upstream providers, resolved per request and
|
|
11817
|
+
* forwarded to with the user's own key.
|
|
11818
|
+
*
|
|
11819
|
+
* WHY A SECOND LANE BESIDE THE POOL
|
|
11820
|
+
*
|
|
11821
|
+
* The pool serves exactly one family of gateways — WorkBuddy, CN and
|
|
11822
|
+
* international — from accounts the user already signed in on the desktop app.
|
|
11823
|
+
* This module serves the orthogonal case: a provider the user holds a key for,
|
|
11824
|
+
* reached at that provider's own endpoint, with no account rotation because
|
|
11825
|
+
* there is no account to rotate. Same card, same model picker, different wire.
|
|
11826
|
+
*
|
|
11827
|
+
* WHAT IT DELIBERATELY DOES NOT DO
|
|
11828
|
+
*
|
|
11829
|
+
* It does not invent endpoints. Every URL here comes from the registry record or
|
|
11830
|
+
* from a base-URL override the user typed; a provider whose endpoint cannot be
|
|
11831
|
+
* composed is reported as such rather than being probed at a guessed URL, because
|
|
11832
|
+
* "no listing" and "provider is down" must not read the same to the user.
|
|
11833
|
+
*
|
|
11834
|
+
* It also does not decide whether a provider is USABLE — that is the settings
|
|
11835
|
+
* layer's call (`providerAuthUsable`). This module resolves what a request
|
|
11836
|
+
* needs, and refuses with a message naming what is missing.
|
|
11837
|
+
*
|
|
11838
|
+
* KEYS
|
|
11839
|
+
*
|
|
11840
|
+
* Every string that can reach a message — a failure, a body snippet, a probe's
|
|
11841
|
+
* answer — passes through {@link redactAggregateSecret} first. Providers echo
|
|
11842
|
+
* rejected credentials back (a 401 body routinely repeats the key), and this
|
|
11843
|
+
* text is folded into messages the host writes to its durable session log.
|
|
11844
|
+
*
|
|
11845
|
+
* @module dsh-xdpool/aggregate
|
|
11846
|
+
*/
|
|
11847
|
+
const WIRE_BY_FORMAT = Object.freeze({
|
|
11848
|
+
"openai-chat": "chat",
|
|
11849
|
+
"openai-responses": "responses",
|
|
11850
|
+
"anthropic-messages": "messages"
|
|
11851
|
+
});
|
|
11852
|
+
/**
|
|
11853
|
+
* Wire suffixes, needed only for the one record the registry gives no base URL
|
|
11854
|
+
* to (Azure names its endpoint per deployment, so `chatEndpoint` is null there
|
|
11855
|
+
* and there is no registry endpoint to read the suffix off).
|
|
11856
|
+
*/
|
|
11857
|
+
const CHAT_SUFFIX = Object.freeze({
|
|
11858
|
+
"openai-chat": "/chat/completions",
|
|
11859
|
+
"openai-responses": "/responses",
|
|
11860
|
+
"anthropic-messages": "/messages"
|
|
11861
|
+
});
|
|
11862
|
+
/**
|
|
11863
|
+
* Capacities for a row the registry describes only by id.
|
|
11864
|
+
*
|
|
11865
|
+
* Deliberately conservative, and the reason is visible in the number: most
|
|
11866
|
+
* registry rows publish no context window at all. Publishing a plausible one
|
|
11867
|
+
* would put a fabricated field in front of the user — and unlike a display
|
|
11868
|
+
* label, a wrong window is a wrong capability, so DSH would hand the provider a
|
|
11869
|
+
* prompt it cannot accept.
|
|
11870
|
+
*/
|
|
11871
|
+
const DEFAULT_CONTEXT_WINDOW = 131072;
|
|
11872
|
+
const DEFAULT_MAX_OUTPUT = 32768;
|
|
11873
|
+
/** Listing round budget. Generous for a small JSON document, short for a UI. */
|
|
11874
|
+
const LISTING_TIMEOUT_MS = 15e3;
|
|
11875
|
+
/** One turn's budget, matching the pool shim's idle ceiling. */
|
|
11876
|
+
const TURN_TIMEOUT_MS = 3e5;
|
|
11877
|
+
/** Sent when the registry does not declare one (kimchi, and most of the roster). */
|
|
11878
|
+
const USER_AGENT = "dsh-xdpool/2.1";
|
|
11879
|
+
/** How much of an upstream body is quoted back in a failure message. */
|
|
11880
|
+
const BODY_SNIPPET_CHARS = 400;
|
|
11881
|
+
/** A trimmed non-empty string, or ''. */
|
|
11882
|
+
function text$4(value) {
|
|
11883
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : "";
|
|
11884
|
+
}
|
|
11885
|
+
/** A positive finite number, or undefined. */
|
|
11886
|
+
function number(value) {
|
|
11887
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : void 0;
|
|
11888
|
+
}
|
|
11889
|
+
function isRecord$3(value) {
|
|
11890
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11891
|
+
}
|
|
11892
|
+
/**
|
|
11893
|
+
* One aggregate-lane failure, carrying the class the caller maps onto HTTP.
|
|
11894
|
+
*
|
|
11895
|
+
* `kind` reuses the pool's vocabulary (`UpstreamErrorKind` in `upstream.ts`) so
|
|
11896
|
+
* the shim's existing error→status table serves both lanes: a third-party 429
|
|
11897
|
+
* is a `soft_rate`, a 401 is a `session_dead`, and neither needs a second table
|
|
11898
|
+
* that could drift from the first.
|
|
11899
|
+
*/
|
|
11900
|
+
var AggregateError = class extends Error {
|
|
11901
|
+
kind;
|
|
11902
|
+
status;
|
|
11903
|
+
constructor(kind, message, status = 0) {
|
|
11904
|
+
super(message);
|
|
11905
|
+
this.name = "AggregateError";
|
|
11906
|
+
this.kind = kind;
|
|
11907
|
+
this.status = status;
|
|
11908
|
+
}
|
|
11909
|
+
};
|
|
11910
|
+
/** Remove a credential from anything that may reach a message. */
|
|
11911
|
+
function redactAggregateSecret(value, apiKey) {
|
|
11912
|
+
let source = typeof value === "string" ? value : String(value ?? "");
|
|
11913
|
+
const key = text$4(apiKey);
|
|
11914
|
+
if (key === "") return source;
|
|
11915
|
+
const escaped = JSON.stringify(key).slice(1, -1);
|
|
11916
|
+
for (const needle of escaped === key ? [key] : [key, escaped]) if (source.includes(needle)) source = source.split(needle).join("[redacted]");
|
|
11917
|
+
return source;
|
|
11918
|
+
}
|
|
11919
|
+
/** A base URL a request can actually be sent to. */
|
|
11920
|
+
function hasPlaceholder(baseUrl) {
|
|
11921
|
+
return /\{[^}]*\}/.test(baseUrl);
|
|
11922
|
+
}
|
|
11923
|
+
/** The user's `baseUrl` override when present, else the registry's own. */
|
|
11924
|
+
function effectiveBaseUrl(provider, setting) {
|
|
11925
|
+
const override = text$4(setting.baseUrl);
|
|
11926
|
+
return (override !== "" ? override : text$4(provider.baseUrl)).replace(/\/+$/, "");
|
|
11927
|
+
}
|
|
11928
|
+
/**
|
|
11929
|
+
* The provider's listing URL, moved by a `baseUrl` override when — and only
|
|
11930
|
+
* when — the declared listing actually lives under the registry base.
|
|
11931
|
+
*
|
|
11932
|
+
* Three providers (cohere, deepinfra, ollama_cloud) publish their listing on
|
|
11933
|
+
* another path. Rewriting a URL that never pointed at the override would invent
|
|
11934
|
+
* one, and a guessed listing is worse than none.
|
|
11935
|
+
*/
|
|
11936
|
+
function effectiveModelsUrl(provider, setting) {
|
|
11937
|
+
const declared = modelsEndpoint(provider);
|
|
11938
|
+
if (declared === null) return null;
|
|
11939
|
+
const override = text$4(setting.baseUrl).replace(/\/+$/, "");
|
|
11940
|
+
const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
|
|
11941
|
+
if (override === "" || registryBase === "" || !declared.startsWith(registryBase)) return declared;
|
|
11942
|
+
return `${override}${declared.slice(registryBase.length)}`;
|
|
11943
|
+
}
|
|
11944
|
+
/**
|
|
11945
|
+
* The full URL a turn is posted to; null when the provider has no dialable
|
|
11946
|
+
* endpoint (Azure without a user base URL, or a base still carrying the
|
|
11947
|
+
* registry's `{account_id}`-style placeholder).
|
|
11948
|
+
*/
|
|
11949
|
+
function effectiveChatUrl(provider, setting) {
|
|
11950
|
+
const override = text$4(setting.baseUrl).replace(/\/+$/, "");
|
|
11951
|
+
const declared = chatEndpoint(provider);
|
|
11952
|
+
if (declared !== null) {
|
|
11953
|
+
const registryBase = text$4(provider.baseUrl).replace(/\/+$/, "");
|
|
11954
|
+
const url = override === "" || registryBase === "" ? declared : `${override}${declared.slice(registryBase.length)}`;
|
|
11955
|
+
return hasPlaceholder(url) ? null : url;
|
|
11956
|
+
}
|
|
11957
|
+
const suffix = CHAT_SUFFIX[provider.format];
|
|
11958
|
+
if (override === "" || suffix === void 0 || hasPlaceholder(override)) return null;
|
|
11959
|
+
return `${override}${suffix}`;
|
|
11960
|
+
}
|
|
11961
|
+
/** The per-provider settings object, or `{}` when the store names none. */
|
|
11962
|
+
function settingFor(store, providerId) {
|
|
11963
|
+
return store.providers?.[providerId] ?? {};
|
|
11964
|
+
}
|
|
11965
|
+
/**
|
|
11966
|
+
* Resolve one provider's effective settings.
|
|
11967
|
+
*
|
|
11968
|
+
* Throws {@link AggregateError} with a `client` kind for every state the user
|
|
11969
|
+
* fixes in the settings page — an unknown id, a missing key, a placeholder base
|
|
11970
|
+
* URL. A `client` kind is deliberate: none of these is a transport fault, so
|
|
11971
|
+
* none of them should be retried against a different provider.
|
|
11972
|
+
*
|
|
11973
|
+
* `options.requireEnabled` exists for the DISCOVERY calls. Fetching a provider's
|
|
11974
|
+
* model list publishes nothing: it asks "does this endpoint answer, and with
|
|
11975
|
+
* what", and writing the answer back is a separate step the user also takes.
|
|
11976
|
+
* Gating it on the enable switch made a read-only button report
|
|
11977
|
+
* `"OpenCode Free" is not enabled in the aggregate settings` — a failure to
|
|
11978
|
+
* LOOK, dressed up as a failure to connect, on rows that carry no key field and
|
|
11979
|
+
* so offered no way to answer it. Discovery therefore does not require the
|
|
11980
|
+
* switch; publishing still does, in {@link buildAggregateCatalog} and
|
|
11981
|
+
* {@link resolveAggregateProvider} as called by the serving paths.
|
|
11982
|
+
*/
|
|
11983
|
+
function resolveAggregateProvider(store, providerId, options = {}) {
|
|
11984
|
+
const id = text$4(providerId);
|
|
11985
|
+
const provider = id === "" ? null : getProvider(id);
|
|
11986
|
+
if (provider === null) throw new AggregateError("client", `the aggregate lane has no provider "${id === "" ? String(providerId) : id}"`);
|
|
11987
|
+
const setting = settingFor(store, provider.id);
|
|
11988
|
+
if (setting.enabled === false) throw new AggregateError("client", `"${provider.name}" is switched off in the aggregate settings`);
|
|
11989
|
+
if (!providerAuthUsable(provider, setting)) {
|
|
11990
|
+
if (provider.auth.kind === "oauth" || provider.auth.kind === "adc") throw new AggregateError("session_dead", `"${provider.name}" signs in with ${provider.auth.kind}, not with an API key, so the aggregate lane does not serve it`);
|
|
11991
|
+
const where = text$4(provider.auth.url) === "" ? "" : ` (the provider issues keys at ${provider.auth.url})`;
|
|
11992
|
+
throw new AggregateError("client", `"${provider.name}" needs an API key: paste one in the aggregate settings${where}`);
|
|
11993
|
+
}
|
|
11994
|
+
if (options.requireEnabled !== false && !providerEnabled(provider, setting)) throw new AggregateError("client", `"${provider.name}" is not enabled in the aggregate settings`);
|
|
11995
|
+
const baseUrl = effectiveBaseUrl(provider, setting);
|
|
11996
|
+
if (baseUrl === "") throw new AggregateError("client", `"${provider.name}" has no base URL of its own — set one in the aggregate settings`);
|
|
11997
|
+
if (hasPlaceholder(baseUrl)) throw new AggregateError("client", `"${provider.name}"'s base URL still carries a registry placeholder — set the real URL in the aggregate settings`);
|
|
11998
|
+
const chatUrl = effectiveChatUrl(provider, setting);
|
|
11999
|
+
if (chatUrl === null) throw new AggregateError("client", `"${provider.name}" has no usable ${provider.format} endpoint`);
|
|
12000
|
+
const apiKeys = orderApiKeys(providerApiKeys(setting), setting.distribution);
|
|
12001
|
+
const apiKey = apiKeys[0] ?? "";
|
|
12002
|
+
const headers = defaultHeaders(provider, apiKey);
|
|
12003
|
+
if (headers["user-agent"] === void 0) headers["user-agent"] = USER_AGENT;
|
|
12004
|
+
return {
|
|
12005
|
+
providerId: provider.id,
|
|
12006
|
+
providerName: provider.name,
|
|
12007
|
+
apiKey,
|
|
12008
|
+
/**
|
|
12009
|
+
* Every key, preferred first — what rotation reads.
|
|
12010
|
+
*
|
|
12011
|
+
* `apiKey` above stays entry 0 because the redaction helper and every existing
|
|
12012
|
+
* caller want one string. This is the field that makes several pasted keys
|
|
12013
|
+
* mean something: a key the vendor rate-limits is swapped for the next one
|
|
12014
|
+
* instead of failing the request.
|
|
12015
|
+
*/
|
|
12016
|
+
apiKeys,
|
|
12017
|
+
baseUrl,
|
|
12018
|
+
format: provider.format,
|
|
12019
|
+
wire: WIRE_BY_FORMAT[provider.format],
|
|
12020
|
+
chatUrl,
|
|
12021
|
+
modelsUrl: effectiveModelsUrl(provider, setting),
|
|
12022
|
+
headers
|
|
12023
|
+
};
|
|
12024
|
+
}
|
|
12025
|
+
/**
|
|
12026
|
+
* The ids a provider should be served with, and where that list came from.
|
|
12027
|
+
*
|
|
12028
|
+
* `discovered` is the roster a fetch returned, `models` is the subset the user
|
|
12029
|
+
* kept, and the choice is applied LAST so it wins over both — otherwise pressing
|
|
12030
|
+
* "Fetch models" would silently undo the narrowing the card just saved.
|
|
12031
|
+
*/
|
|
12032
|
+
function modelIdsFor(provider, setting) {
|
|
12033
|
+
const universe = modelChoicesFor(provider, setting);
|
|
12034
|
+
const source = rosterIsDiscovered(provider, setting) ? "discovered" : "static";
|
|
12035
|
+
const override = stringList(setting.models);
|
|
12036
|
+
if (override.length === 0) return {
|
|
12037
|
+
ids: universe,
|
|
12038
|
+
source
|
|
12039
|
+
};
|
|
12040
|
+
const available = new Set(universe);
|
|
12041
|
+
return {
|
|
12042
|
+
ids: override.filter((id) => available.has(id)),
|
|
12043
|
+
source
|
|
12044
|
+
};
|
|
12045
|
+
}
|
|
12046
|
+
/**
|
|
12047
|
+
* Whether the roster came from a live fetch rather than the registry.
|
|
12048
|
+
*
|
|
12049
|
+
* A pre-split install has `models` and no `discovered`, because until the
|
|
12050
|
+
* chooser existed the only writer of `models` WAS the fetch button. Those ids
|
|
12051
|
+
* are a roster, not a choice, and treating them as one would shrink the vendor
|
|
12052
|
+
* to whatever handful the registry happens to declare.
|
|
12053
|
+
*/
|
|
12054
|
+
function rosterIsDiscovered(provider, setting) {
|
|
12055
|
+
if ((setting.discovered?.length ?? 0) > 0) return true;
|
|
12056
|
+
return stringList(setting.models).length > provider.models.length;
|
|
12057
|
+
}
|
|
12058
|
+
/** Every id a provider could offer: its roster, for the card's chooser. */
|
|
12059
|
+
function modelChoicesFor(provider, setting) {
|
|
12060
|
+
const discovered = stringList(setting.discovered);
|
|
12061
|
+
if (discovered.length === 0 && rosterIsDiscovered(provider, setting)) return stringList(setting.models);
|
|
12062
|
+
return discovered.length > 0 ? discovered : provider.models.map((model) => model.id);
|
|
12063
|
+
}
|
|
12064
|
+
/** A de-duplicated list of non-empty trimmed strings from an untrusted array. */
|
|
12065
|
+
function stringList(value) {
|
|
12066
|
+
if (!Array.isArray(value)) return [];
|
|
12067
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12068
|
+
const ids = [];
|
|
12069
|
+
for (const raw of value) {
|
|
12070
|
+
const id = text$4(raw);
|
|
12071
|
+
if (id === "" || seen.has(id)) continue;
|
|
12072
|
+
seen.add(id);
|
|
12073
|
+
ids.push(id);
|
|
12074
|
+
}
|
|
12075
|
+
return ids;
|
|
12076
|
+
}
|
|
12077
|
+
/**
|
|
12078
|
+
* One catalog row.
|
|
12079
|
+
*
|
|
12080
|
+
* Capacities come from the registry's model row where it has them, and fall back
|
|
12081
|
+
* to this module's own defaults where it does not (most rows carry no context
|
|
12082
|
+
* window, and none carries an output ceiling — the registry records ids and
|
|
12083
|
+
* published capacities, not a serving contract).
|
|
12084
|
+
*/
|
|
12085
|
+
function rowOf(provider, modelId, source) {
|
|
12086
|
+
const declared = provider.models.find((model) => model.id === modelId);
|
|
12087
|
+
const contextWindow = number(declared?.contextWindow) ?? DEFAULT_CONTEXT_WINDOW;
|
|
12088
|
+
const label = text$4(declared?.name) !== "" ? text$4(declared?.name) : modelId;
|
|
12089
|
+
return {
|
|
12090
|
+
id: providerModelId(provider.id, modelId),
|
|
12091
|
+
name: `${provider.name} ${label}`,
|
|
12092
|
+
providerId: provider.id,
|
|
11217
12093
|
modelId,
|
|
11218
12094
|
contextWindow,
|
|
11219
12095
|
maxOutputTokens: Math.min(DEFAULT_MAX_OUTPUT, contextWindow),
|
|
@@ -11517,30 +12393,80 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
|
|
|
11517
12393
|
stream: true
|
|
11518
12394
|
});
|
|
11519
12395
|
const controller = new AbortController();
|
|
12396
|
+
/**
|
|
12397
|
+
* Every payload goes through here first, so the tracker sees the first byte and
|
|
12398
|
+
* any usage frame without the transport caring.
|
|
12399
|
+
*
|
|
12400
|
+
* Wrapped rather than instrumented at each `onData` call site: this function has
|
|
12401
|
+
* four of them, and one missed would silently under-report the turn's tokens.
|
|
12402
|
+
*/
|
|
12403
|
+
const emit = (payload) => {
|
|
12404
|
+
tracker.markFirstByte();
|
|
12405
|
+
try {
|
|
12406
|
+
tracker.observe(JSON.parse(payload));
|
|
12407
|
+
} catch {}
|
|
12408
|
+
options.onData(payload);
|
|
12409
|
+
};
|
|
12410
|
+
/**
|
|
12411
|
+
* Measures this physical attempt for the ledger.
|
|
12412
|
+
*
|
|
12413
|
+
* Created before the request so `markFirstByte` reports from the moment the
|
|
12414
|
+
* caller asked, and kept in the outer scope so the `finally` can file the call
|
|
12415
|
+
* whether it succeeded or threw — a broken stream is a failed call from the
|
|
12416
|
+
* user's side of the wire, and the panel must count it rather than show nothing.
|
|
12417
|
+
*/
|
|
12418
|
+
const tracker = new StreamTracker();
|
|
12419
|
+
let delivered = false;
|
|
11520
12420
|
const timeoutMs = options.timeoutMs ?? TURN_TIMEOUT_MS;
|
|
11521
12421
|
const onCallerAbort = () => controller.abort();
|
|
11522
12422
|
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
11523
12423
|
try {
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
12424
|
+
/**
|
|
12425
|
+
* POST, rotating the key when the vendor refuses the one we used.
|
|
12426
|
+
*
|
|
12427
|
+
* A provider holding several keys is a user who already knows a single key
|
|
12428
|
+
* gets rate-limited. Failing the turn on a 429/401 from key #1 wastes the very
|
|
12429
|
+
* thing they set up: the alternates. So a REFUSAL that looks like "this key,
|
|
12430
|
+
* not this request" retries with the next key.
|
|
12431
|
+
*
|
|
12432
|
+
* Only the request-credential classes retry — `soft_rate` (429) and
|
|
12433
|
+
* `session_dead` (401). A `client` (400), `not_found` (404), `hard_credit`
|
|
12434
|
+
* (402) or `server` (5xx) failure is about the request or the account balance
|
|
12435
|
+
* and would fail identically on every key, so retrying would only multiply the
|
|
12436
|
+
* wait. Keys are tried in order and each at most once, so the attempt count is
|
|
12437
|
+
* bounded by how many the user pasted.
|
|
12438
|
+
*/
|
|
12439
|
+
const keys = resolved.apiKeys.length === 0 ? [""] : resolved.apiKeys;
|
|
12440
|
+
let response;
|
|
12441
|
+
let usedKey = resolved.apiKey;
|
|
12442
|
+
for (const [index, key] of keys.entries()) {
|
|
12443
|
+
usedKey = key;
|
|
12444
|
+
const headers = {
|
|
11527
12445
|
...resolved.headers,
|
|
11528
12446
|
accept: "text/event-stream"
|
|
11529
|
-
}
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
|
|
11533
|
-
|
|
11534
|
-
|
|
11535
|
-
|
|
12447
|
+
};
|
|
12448
|
+
if (resolved.apiKey !== "") {
|
|
12449
|
+
for (const [name, value] of Object.entries(headers)) if (value === resolved.apiKey) headers[name] = key;
|
|
12450
|
+
}
|
|
12451
|
+
response = await fetch(resolved.chatUrl, {
|
|
12452
|
+
method: "POST",
|
|
12453
|
+
headers,
|
|
12454
|
+
body: bodyText,
|
|
12455
|
+
redirect: "error",
|
|
12456
|
+
signal: controller.signal
|
|
12457
|
+
});
|
|
12458
|
+
if (response.ok) break;
|
|
12459
|
+
const retryAfter = retryAfterMs(response);
|
|
11536
12460
|
const refusal = await response.text().catch(() => "");
|
|
11537
|
-
|
|
12461
|
+
const failure = classifyUpstream(response.status, refusal, retryAfter, key);
|
|
12462
|
+
if (!(failure.kind === "soft_rate" || failure.kind === "session_dead") || index === keys.length - 1) throw failure;
|
|
11538
12463
|
}
|
|
11539
12464
|
if (response.body === null) throw new AggregateError("server", "the provider returned no body", response.status);
|
|
11540
12465
|
const head = await readSseHead(response.body, controller.signal, timeoutMs);
|
|
11541
12466
|
if (head.shape === "empty") throw new AggregateError("server", "the provider returned no body", response.status);
|
|
11542
12467
|
if (head.shape === "sse") {
|
|
11543
|
-
for (const payload of head.payloads)
|
|
12468
|
+
for (const payload of head.payloads) emit(payload);
|
|
12469
|
+
delivered = true;
|
|
11544
12470
|
return { status: response.status };
|
|
11545
12471
|
}
|
|
11546
12472
|
let raw = head.text;
|
|
@@ -11562,8 +12488,9 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
|
|
|
11562
12488
|
if (/^\s*<(!doctype|html[\s>])/i.test(raw)) throw new AggregateError("client", `the provider's front proxy answered HTTP ${response.status} with an HTML error page`, response.status);
|
|
11563
12489
|
throw new AggregateError("server", `unexpected non-stream response: ${redactAggregateSecret(raw, resolved.apiKey).slice(0, 200)}`, response.status);
|
|
11564
12490
|
}
|
|
11565
|
-
if (isRecord$3(payload) && (payload.error !== void 0 || payload.type === "error")) throw classifyUpstream(response.status, raw,
|
|
11566
|
-
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;
|
|
11567
12494
|
return { status: response.status };
|
|
11568
12495
|
} catch (error) {
|
|
11569
12496
|
if (error instanceof AggregateError) throw error;
|
|
@@ -11573,6 +12500,18 @@ async function forwardAggregateStreamed(resolved, modelId, body, options) {
|
|
|
11573
12500
|
throw new AggregateError("server", `the request to ${resolved.providerName} failed: ${redactAggregateSecret(cause, resolved.apiKey)}`);
|
|
11574
12501
|
} finally {
|
|
11575
12502
|
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
12503
|
+
tracker.finish(Date.now());
|
|
12504
|
+
options.usage?.recorder.record({
|
|
12505
|
+
channel: resolved.providerId,
|
|
12506
|
+
region: "",
|
|
12507
|
+
model,
|
|
12508
|
+
turnId: options.usage.turnId,
|
|
12509
|
+
at: Date.now(),
|
|
12510
|
+
ok: delivered,
|
|
12511
|
+
...tracker.ttftMs === void 0 ? {} : { ttftMs: tracker.ttftMs },
|
|
12512
|
+
...tracker.decodeMs === void 0 ? {} : { decodeMs: tracker.decodeMs },
|
|
12513
|
+
...tracker.usage === void 0 ? {} : { usage: tracker.usage }
|
|
12514
|
+
});
|
|
11576
12515
|
}
|
|
11577
12516
|
}
|
|
11578
12517
|
/**
|
|
@@ -12230,65 +13169,6 @@ function createWorkBuddyAdapter(options) {
|
|
|
12230
13169
|
};
|
|
12231
13170
|
}
|
|
12232
13171
|
//#endregion
|
|
12233
|
-
//#region src/billion-mount.ts
|
|
12234
|
-
/** The package name the loader resolves, exactly as our patch row names it. */
|
|
12235
|
-
const BILLION_PACKAGE = "billion-context";
|
|
12236
|
-
/**
|
|
12237
|
-
* Is this package installed and able to run?
|
|
12238
|
-
*
|
|
12239
|
-
* Any entry whose name matches counts — including one whose fiber has not
|
|
12240
|
-
* started yet. When this function still decided whether to mount, a fiber-less
|
|
12241
|
-
* entry had to be treated as absent: treating an import-in-flight as present
|
|
12242
|
-
* would have skipped the mount and left the compressor never starting. Now that
|
|
12243
|
-
* nothing mounts, the distinction only picks a label, and the honest one is
|
|
12244
|
-
* "installed": a user watching billion-context's own boot does not want the
|
|
12245
|
-
* card telling them it is missing while its proxy is half-way up.
|
|
12246
|
-
*
|
|
12247
|
-
* A DISABLED entry does not count. `loader.entries()` yields disabled rows too
|
|
12248
|
-
* — the host skips starting them, it does not drop them — and our own patch
|
|
12249
|
-
* row is disabled on purpose. Counting it would make every profile that never
|
|
12250
|
-
* installed billion-context read as "installed", which is precisely the lie the
|
|
12251
|
-
* card must not tell.
|
|
12252
|
-
*/
|
|
12253
|
-
function mountedInLoader(loader, packageName) {
|
|
12254
|
-
for (const entry of loader.entries()) {
|
|
12255
|
-
if (entry.options?.name !== packageName) continue;
|
|
12256
|
-
if (entry.options?.disabled === true) continue;
|
|
12257
|
-
return true;
|
|
12258
|
-
}
|
|
12259
|
-
return false;
|
|
12260
|
-
}
|
|
12261
|
-
/**
|
|
12262
|
-
* Report whether the compressor is already running. Never mounts.
|
|
12263
|
-
*
|
|
12264
|
-
* Resolves rather than rejects on every path: a compressor that will not start
|
|
12265
|
-
* is a card that says so, never a pool that fails to boot. The pool's own model
|
|
12266
|
-
* registry has nothing to do with compression, and one missing optional feature
|
|
12267
|
-
* must not take the accounts down with it.
|
|
12268
|
-
*/
|
|
12269
|
-
async function mountBillionContext(ctx, logger) {
|
|
12270
|
-
try {
|
|
12271
|
-
let loader;
|
|
12272
|
-
ctx.inject(["loader"], (scoped) => {
|
|
12273
|
-
loader = scoped.loader;
|
|
12274
|
-
});
|
|
12275
|
-
if (loader === void 0) return {
|
|
12276
|
-
state: "unavailable",
|
|
12277
|
-
reason: "the host exposes no plugin loader"
|
|
12278
|
-
};
|
|
12279
|
-
if (mountedInLoader(loader, BILLION_PACKAGE)) {
|
|
12280
|
-
logger.info?.(`[xdpool] billion-context is already mounted; leaving it alone`);
|
|
12281
|
-
return { state: "already-mounted" };
|
|
12282
|
-
}
|
|
12283
|
-
return { state: "not-required" };
|
|
12284
|
-
} catch (error) {
|
|
12285
|
-
return {
|
|
12286
|
-
state: "unavailable",
|
|
12287
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
12288
|
-
};
|
|
12289
|
-
}
|
|
12290
|
-
}
|
|
12291
|
-
//#endregion
|
|
12292
13172
|
//#region src/pack-accounts.ts
|
|
12293
13173
|
/**
|
|
12294
13174
|
* Channel ids whose accounts this plugin serves itself.
|
|
@@ -12343,6 +13223,7 @@ function packCredentialToWorkBuddy(entry, document) {
|
|
|
12343
13223
|
const expiresAtMs = expiryToMs(document.expires_at) || expiryToMs(entry.expiresAt);
|
|
12344
13224
|
const nickname = text$3(document.nickname) ?? text$3(entry.nickname);
|
|
12345
13225
|
const uid = text$3(document.user_id);
|
|
13226
|
+
const enterpriseId = text$3(document.enterprise_id);
|
|
12346
13227
|
return {
|
|
12347
13228
|
accessToken,
|
|
12348
13229
|
refreshToken: text$3(document.refresh_token) ?? "",
|
|
@@ -12350,7 +13231,7 @@ function packCredentialToWorkBuddy(entry, document) {
|
|
|
12350
13231
|
...expiryToMs(document.refresh_expires_at) > 0 ? { refreshExpiresAtMs: expiryToMs(document.refresh_expires_at) } : {},
|
|
12351
13232
|
...nickname === void 0 ? {} : { nickname },
|
|
12352
13233
|
...uid === void 0 ? {} : { uid },
|
|
12353
|
-
...
|
|
13234
|
+
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
12354
13235
|
domain,
|
|
12355
13236
|
sourcePath: `channel-pack:${provider}:${refName}`
|
|
12356
13237
|
};
|
|
@@ -12486,31 +13367,123 @@ function imageUrlOf(block, resolveImage, warnings) {
|
|
|
12486
13367
|
...typeof block.path === "string" ? { path: block.path } : {},
|
|
12487
13368
|
...typeof block.url === "string" ? { url: block.url } : {}
|
|
12488
13369
|
});
|
|
12489
|
-
if (resolved !== void 0 && resolved !== "") return resolved;
|
|
13370
|
+
if (resolved !== void 0 && resolved !== "") return resolved;
|
|
13371
|
+
}
|
|
13372
|
+
const nested = block.image_url;
|
|
13373
|
+
const direct = typeof nested === "string" ? nested : text$2(block.url ?? block.data ?? (isRecord$2(nested) ? nested.url : void 0));
|
|
13374
|
+
if (direct.startsWith("data:")) return direct;
|
|
13375
|
+
if (direct.startsWith("http://") || direct.startsWith("https://")) return direct;
|
|
13376
|
+
warnings.push("image-dropped");
|
|
13377
|
+
}
|
|
13378
|
+
/**
|
|
13379
|
+
* Drop tool calls nobody answered, and tool results nobody called.
|
|
13380
|
+
*
|
|
13381
|
+
* Every wire spells tool traffic as a PAIR: an assistant `tool_calls` entry with
|
|
13382
|
+
* an id, and a `role: 'tool'` message answering that same id. Upstream rejects a
|
|
13383
|
+
* conversation that breaks the pairing — and it rejects it EVERY TURN from then
|
|
13384
|
+
* on, because the offending messages stay in the history. A single interrupted
|
|
13385
|
+
* turn therefore makes a session permanently unusable, which is exactly what
|
|
13386
|
+
* users report as "this conversation broke and won't come back".
|
|
13387
|
+
*
|
|
13388
|
+
* Three shapes are repaired, all by DROPPING rather than inventing:
|
|
13389
|
+
*
|
|
13390
|
+
* 1. a `tool_calls` entry with no answer — the model called a tool, the turn
|
|
13391
|
+
* was interrupted, and the result never arrived. Keeping the call and
|
|
13392
|
+
* fabricating an answer would teach the model a lie; dropping the call
|
|
13393
|
+
* leaves the assistant text intact and the conversation legible.
|
|
13394
|
+
* 2. a `tool` message whose id answers no call — the call was pruned in an
|
|
13395
|
+
* earlier turn, or the result outlived its call.
|
|
13396
|
+
* 3. a `tool_calls` entry with an EMPTY name — it is "answered" by any result
|
|
13397
|
+
* but cannot be replayed: upstream validates the name. Responding side is
|
|
13398
|
+
* already safe (an unknown name is held back rather than streamed), but the
|
|
13399
|
+
* HISTORY would otherwise keep shipping it.
|
|
13400
|
+
*
|
|
13401
|
+
* Dropping is safe here only because the alternative is a hard 400 that kills
|
|
13402
|
+
* every subsequent turn. It is reported through `warnings` so the drop is not
|
|
13403
|
+
* silent — the same channel the request body already uses for other repairs.
|
|
13404
|
+
*/
|
|
13405
|
+
function repairToolPairing(messages, warnings) {
|
|
13406
|
+
const called = /* @__PURE__ */ new Set();
|
|
13407
|
+
const answered = /* @__PURE__ */ new Set();
|
|
13408
|
+
for (const message of messages) {
|
|
13409
|
+
for (const call of toolCallsOf(message)) if (call.id !== "" && call.name !== "") called.add(call.id);
|
|
13410
|
+
for (const result of toolResultsOf(message)) if (result.callId !== "") answered.add(result.callId);
|
|
13411
|
+
}
|
|
13412
|
+
let droppedCalls = 0;
|
|
13413
|
+
let droppedResults = 0;
|
|
13414
|
+
const out = [];
|
|
13415
|
+
for (const message of messages) {
|
|
13416
|
+
if (message.role === "tool") {
|
|
13417
|
+
const id = text$2(message.tool_call_id ?? message.toolCallId ?? message.id);
|
|
13418
|
+
if (id !== "" && !called.has(id)) {
|
|
13419
|
+
droppedResults += 1;
|
|
13420
|
+
continue;
|
|
13421
|
+
}
|
|
13422
|
+
out.push(message);
|
|
13423
|
+
continue;
|
|
13424
|
+
}
|
|
13425
|
+
const calls = toolCallsOf(message);
|
|
13426
|
+
if (calls.length === 0) {
|
|
13427
|
+
out.push(message);
|
|
13428
|
+
continue;
|
|
13429
|
+
}
|
|
13430
|
+
const kept = calls.filter((call) => call.id !== "" && call.name !== "" && answered.has(call.id));
|
|
13431
|
+
if (kept.length === calls.length) {
|
|
13432
|
+
out.push(message);
|
|
13433
|
+
continue;
|
|
13434
|
+
}
|
|
13435
|
+
droppedCalls += calls.length - kept.length;
|
|
13436
|
+
if (kept.length === 0) {
|
|
13437
|
+
if (textOf(blocksOf(message.content)) === "") continue;
|
|
13438
|
+
const stripped = { ...message };
|
|
13439
|
+
delete stripped.tool_calls;
|
|
13440
|
+
delete stripped.toolCalls;
|
|
13441
|
+
out.push(stripped);
|
|
13442
|
+
continue;
|
|
13443
|
+
}
|
|
13444
|
+
out.push({
|
|
13445
|
+
...message,
|
|
13446
|
+
tool_calls: kept
|
|
13447
|
+
});
|
|
12490
13448
|
}
|
|
12491
|
-
|
|
12492
|
-
|
|
12493
|
-
|
|
12494
|
-
if (direct.startsWith("http://") || direct.startsWith("https://")) return direct;
|
|
12495
|
-
warnings.push("image-dropped");
|
|
13449
|
+
if (droppedCalls > 0) warnings.push(`${String(droppedCalls)} unanswered tool call(s) were dropped so the conversation can continue`);
|
|
13450
|
+
if (droppedResults > 0) warnings.push(`${String(droppedResults)} orphaned tool result(s) were dropped so the conversation can continue`);
|
|
13451
|
+
return out;
|
|
12496
13452
|
}
|
|
12497
|
-
/**
|
|
13453
|
+
/**
|
|
13454
|
+
* Every tool result a message carries, in order.
|
|
13455
|
+
*
|
|
13456
|
+
* The `role: 'tool'` case is the one that was silent and expensive: DSH's own
|
|
13457
|
+
* tool messages carry the RESULT as their content — there is no `tool-result`
|
|
13458
|
+
* block wrapper to find. Filtering for that wrapper therefore returned `[]`, and
|
|
13459
|
+
* the call sites skip a tool message with no results (`continue`), so the whole
|
|
13460
|
+
* result vanished from all three wires. The model then saw a conversation in
|
|
13461
|
+
* which it had called a tool and never learned the outcome, and re-issued the
|
|
13462
|
+
* same call forever — reported as "the tool returned no result" (#113).
|
|
13463
|
+
*
|
|
13464
|
+
* So: a tool message's content IS the result. Only a non-tool message is
|
|
13465
|
+
* searched for the wrapper blocks (that is the Anthropic-shaped spelling, where
|
|
13466
|
+
* a `user` turn can carry `tool-result` blocks).
|
|
13467
|
+
*/
|
|
12498
13468
|
function toolResultsOf(message) {
|
|
12499
|
-
if (message.role
|
|
12500
|
-
const
|
|
12501
|
-
|
|
13469
|
+
if (message.role === "tool") {
|
|
13470
|
+
const callId = text$2(message.tool_call_id ?? message.toolCallId ?? message.id);
|
|
13471
|
+
const blocks = blocksOf(message.content);
|
|
12502
13472
|
return [{
|
|
12503
|
-
callId
|
|
12504
|
-
content: blocks
|
|
13473
|
+
callId,
|
|
13474
|
+
content: blocks.length > 0 ? blocks : [{
|
|
13475
|
+
type: "text",
|
|
13476
|
+
text: typeof message.content === "string" ? message.content : ""
|
|
13477
|
+
}],
|
|
13478
|
+
...message.is_error === true || message.isError === true ? { failed: true } : {}
|
|
12505
13479
|
}];
|
|
12506
13480
|
}
|
|
12507
|
-
const out = [];
|
|
12508
13481
|
const blocks = blocksOf(message.content).filter((block) => block.type === "tool-result");
|
|
12509
|
-
|
|
12510
|
-
|
|
12511
|
-
|
|
12512
|
-
|
|
12513
|
-
|
|
13482
|
+
if (blocks.length === 0) return [];
|
|
13483
|
+
return [{
|
|
13484
|
+
callId: text$2(message.tool_call_id ?? message.toolCallId ?? message.id),
|
|
13485
|
+
content: blocks
|
|
13486
|
+
}];
|
|
12514
13487
|
}
|
|
12515
13488
|
/** The tool call a message's `tool_calls` array holds, when it holds one. */
|
|
12516
13489
|
function toolCallsOf(message) {
|
|
@@ -12575,7 +13548,7 @@ function toolDefsFor(tools, wire) {
|
|
|
12575
13548
|
*/
|
|
12576
13549
|
function requestBodyFor(wire, body, options) {
|
|
12577
13550
|
const { modelId, resolveImage, warnings } = options;
|
|
12578
|
-
const messages = Array.isArray(body.messages) ? body.messages.filter(isRecord$2) : [];
|
|
13551
|
+
const messages = repairToolPairing(Array.isArray(body.messages) ? body.messages.filter(isRecord$2) : [], warnings);
|
|
12579
13552
|
const stream = body.stream === true;
|
|
12580
13553
|
const reasoningEffort = text$2(body.reasoning_effort);
|
|
12581
13554
|
const out = {
|
|
@@ -12751,7 +13724,8 @@ function claudeTurns(messages, resolveImage, warnings) {
|
|
|
12751
13724
|
content: [{
|
|
12752
13725
|
type: "tool_result",
|
|
12753
13726
|
tool_use_id: result.callId,
|
|
12754
|
-
content: body !== "" ? body : content.length > 0 ? content : "(no output)"
|
|
13727
|
+
content: body !== "" ? body : content.length > 0 ? content : "(no output)",
|
|
13728
|
+
...result.failed === true ? { is_error: true } : {}
|
|
12755
13729
|
}]
|
|
12756
13730
|
});
|
|
12757
13731
|
}
|
|
@@ -13758,6 +14732,33 @@ function withSessionQuotaHint(error, retryAfterMs) {
|
|
|
13758
14732
|
return new AggregateError(error.kind, `${error.message} — this quota is counted per conversation on the free tier, so a new conversation (or another model) starts with its own budget`, error.status);
|
|
13759
14733
|
}
|
|
13760
14734
|
/**
|
|
14735
|
+
* Say what a 403 on this lane actually means (#96).
|
|
14736
|
+
*
|
|
14737
|
+
* The gateway answers a client it does not recognise with `403 FreeTierError`
|
|
14738
|
+
* and no explanation of which of its four fingerprint facts was wrong. That
|
|
14739
|
+
* reaches the caller as a bare `client` failure, and a bare 4xx is what the
|
|
14740
|
+
* host then renders in its own words: the user in #96 saw "当前请求的额度已用尽"
|
|
14741
|
+
* for a request that had never been billed. The wording pointed at waiting,
|
|
14742
|
+
* while the actual cause was "this build's fingerprint no longer matches, update
|
|
14743
|
+
* the plugin" — the two need opposite responses.
|
|
14744
|
+
*
|
|
14745
|
+
* The lane cannot tell them apart from the outside (both look like a refusal
|
|
14746
|
+
* with a thin body), so this does not pretend to. It names the actionable
|
|
14747
|
+
* reading FIRST — the fingerprint, which the user can fix by updating — and
|
|
14748
|
+
* keeps the quota possibility second, because it is the one that fixes itself.
|
|
14749
|
+
*
|
|
14750
|
+
* A 429 keeps its own hint ({@link withSessionQuotaHint}): that one really is
|
|
14751
|
+
* quota, and blurring the two would lose a distinction worth keeping.
|
|
14752
|
+
*/
|
|
14753
|
+
function withFreeTierHint(status, refusal, retryAfterMs) {
|
|
14754
|
+
const base = classifyUpstream(status, refusal, retryAfterMs, "");
|
|
14755
|
+
if (status !== 403) return base;
|
|
14756
|
+
const separator = base.message.indexOf(": ");
|
|
14757
|
+
const detail = separator === -1 ? "" : base.message.slice(separator + 2);
|
|
14758
|
+
const kept = detail === "" ? "" : ` (provider said: ${detail})`;
|
|
14759
|
+
return new AggregateError(base.kind, `the free lane refused this client: its request fingerprint no longer matches what opencode.ai accepts, so this is NOT an exhausted quota and waiting will not help — update dsh-xdpool and try again; if it persists, report this message. The shared free quota is also metered per conversation, so one retry in a fresh conversation covers the case where only that budget is spent${kept}`, status);
|
|
14760
|
+
}
|
|
14761
|
+
/**
|
|
13761
14762
|
* POST one streaming turn and hand back chat-shaped deltas.
|
|
13762
14763
|
*
|
|
13763
14764
|
* The shape of the ANSWER decides how it is read, never its Content-Type: a front
|
|
@@ -13801,7 +14802,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
|
|
|
13801
14802
|
const retryAfter = retryAfterMs(response);
|
|
13802
14803
|
if (!response.ok) {
|
|
13803
14804
|
const refusal = await response.text().catch(() => "");
|
|
13804
|
-
throw withSessionQuotaHint(
|
|
14805
|
+
throw withSessionQuotaHint(withFreeTierHint(response.status, refusal, retryAfter), retryAfter);
|
|
13805
14806
|
}
|
|
13806
14807
|
if (response.body === null) throw new AggregateError("server", "the free lane returned no body", response.status);
|
|
13807
14808
|
let events;
|
|
@@ -13834,7 +14835,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
|
|
|
13834
14835
|
} catch {
|
|
13835
14836
|
continue;
|
|
13836
14837
|
}
|
|
13837
|
-
if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(
|
|
14838
|
+
if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(withFreeTierHint(response.status, event, retryAfter), retryAfter);
|
|
13838
14839
|
const translated = chatChunksFor(wire, decoded, tools);
|
|
13839
14840
|
for (const delta of translated.frames) hooks.onDelta(delta);
|
|
13840
14841
|
if (translated.usage !== void 0) usage = translated.usage;
|
|
@@ -13849,7 +14850,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
|
|
|
13849
14850
|
result.usage = usage;
|
|
13850
14851
|
}
|
|
13851
14852
|
result.suppressedOnly = tools.suppressed();
|
|
13852
|
-
result.finishReason = finish;
|
|
14853
|
+
if (finish !== void 0) result.finishReason = finish;
|
|
13853
14854
|
return result;
|
|
13854
14855
|
}
|
|
13855
14856
|
/**
|
|
@@ -14057,542 +15058,213 @@ function capabilitiesFor(modelId) {
|
|
|
14057
15058
|
canDisableThinking: true
|
|
14058
15059
|
};
|
|
14059
15060
|
}
|
|
14060
|
-
/** Is this id's availability known to depend on where the request leaves from? */
|
|
14061
|
-
function isRegionSensitive(modelId) {
|
|
14062
|
-
const base = baseModelId(modelId);
|
|
14063
|
-
return REGION_SENSITIVE.some((pattern) => pattern.test(base));
|
|
14064
|
-
}
|
|
14065
|
-
/** Title-case a bare upstream id into something a picker can show. */
|
|
14066
|
-
function displayModelName(modelId) {
|
|
14067
|
-
const base = baseModelId(modelId);
|
|
14068
|
-
const known = DISPLAY_NAMES[base];
|
|
14069
|
-
if (known !== void 0) return known;
|
|
14070
|
-
return base.replace(/[-_.]+/gu, " ").replace(/(\d)\s+/gu, "$1 ").trim().split(" ").map((word) => /^\d/u.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
14071
|
-
}
|
|
14072
|
-
/** Parse the gateway's `{"data":[{"id":…}]}` listing. */
|
|
14073
|
-
function parseListing(payload) {
|
|
14074
|
-
const rows = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : Array.isArray(payload) ? payload : [];
|
|
14075
|
-
const out = [];
|
|
14076
|
-
for (const row of rows) {
|
|
14077
|
-
const id = typeof row === "string" ? row : row?.id;
|
|
14078
|
-
if (typeof id === "string" && id !== "") out.push(id);
|
|
14079
|
-
}
|
|
14080
|
-
return out;
|
|
14081
|
-
}
|
|
14082
|
-
/**
|
|
14083
|
-
* The free rows for a raw listing, in listing order.
|
|
14084
|
-
*
|
|
14085
|
-
* The prefix is what the shim routes on, so it is applied here rather than at
|
|
14086
|
-
* the call sites: one place decides the lane's id shape, and every reader gets it
|
|
14087
|
-
* the same way.
|
|
14088
|
-
*/
|
|
14089
|
-
function buildFreeCatalog(ids) {
|
|
14090
|
-
const seen = /* @__PURE__ */ new Set();
|
|
14091
|
-
const rows = [];
|
|
14092
|
-
for (const raw of ids) {
|
|
14093
|
-
const id = raw.trim();
|
|
14094
|
-
if (id === "" || !isFreeLane(id)) continue;
|
|
14095
|
-
const base = baseModelId(id);
|
|
14096
|
-
if (seen.has(base)) continue;
|
|
14097
|
-
seen.add(base);
|
|
14098
|
-
const caps = capabilitiesFor(base);
|
|
14099
|
-
rows.push({
|
|
14100
|
-
id: base,
|
|
14101
|
-
modelId: `${FREE_MODEL_PREFIX}${base}`,
|
|
14102
|
-
name: displayModelName(base),
|
|
14103
|
-
wire: wireFor(base),
|
|
14104
|
-
vision: caps.vision,
|
|
14105
|
-
reasoning: caps.reasoning,
|
|
14106
|
-
contextWindow: positiveInt(caps.contextWindow) ?? FALLBACK_CAPABILITY.contextWindow,
|
|
14107
|
-
maxOutput: positiveInt(caps.maxOutput) ?? FALLBACK_CAPABILITY.maxOutput,
|
|
14108
|
-
canDisableThinking: caps.canDisableThinking,
|
|
14109
|
-
regionSensitive: isRegionSensitive(base)
|
|
14110
|
-
});
|
|
14111
|
-
}
|
|
14112
|
-
return rows;
|
|
14113
|
-
}
|
|
14114
|
-
/** One listing round. Returns the parsed JSON document. */
|
|
14115
|
-
async function fetchFreeListing(options = {}) {
|
|
14116
|
-
const controller = new AbortController();
|
|
14117
|
-
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 15e3);
|
|
14118
|
-
timer.unref?.();
|
|
14119
|
-
let callerAborted = false;
|
|
14120
|
-
const onCallerAbort = () => {
|
|
14121
|
-
callerAborted = true;
|
|
14122
|
-
controller.abort();
|
|
14123
|
-
};
|
|
14124
|
-
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
14125
|
-
try {
|
|
14126
|
-
const response = await freeFetch(`${FREE_UPSTREAM_BASE}${FREE_LISTING_PATH}`, {
|
|
14127
|
-
headers: gatewayHeaders({
|
|
14128
|
-
session: sessionForConversation(options.session),
|
|
14129
|
-
requestId: mintRequestId(),
|
|
14130
|
-
stream: false,
|
|
14131
|
-
accept: "application/json"
|
|
14132
|
-
}),
|
|
14133
|
-
signal: controller.signal
|
|
14134
|
-
});
|
|
14135
|
-
const text = await response.text();
|
|
14136
|
-
if (!response.ok) throw new Error(`the free lane answered HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
14137
|
-
return JSON.parse(text);
|
|
14138
|
-
} catch (error) {
|
|
14139
|
-
if (callerAborted || options.signal?.aborted === true) throw new Error("request aborted");
|
|
14140
|
-
if (error instanceof Error && error.name === "AbortError") throw new Error(`the free lane's listing did not answer within ${String(options.timeoutMs ?? 15e3)}ms`);
|
|
14141
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
14142
|
-
} finally {
|
|
14143
|
-
clearTimeout(timer);
|
|
14144
|
-
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
14145
|
-
}
|
|
14146
|
-
}
|
|
14147
|
-
/**
|
|
14148
|
-
* The static roster, used until the first listing lands and again whenever one
|
|
14149
|
-
* fails.
|
|
14150
|
-
*
|
|
14151
|
-
* An empty picker is the one outcome a user reads as "the plugin broke", so the
|
|
14152
|
-
* lane ships with rows it can serve without ever having reached the network.
|
|
14153
|
-
* These are the ids the gateway is known to serve免密 today; a row that later
|
|
14154
|
-
* disappears costs one dead model entry, while an empty lane costs everything.
|
|
14155
|
-
*/
|
|
14156
|
-
const FALLBACK_FREE_MODEL_IDS = Object.freeze([
|
|
14157
|
-
"muse-spark-1.3-contributor-free",
|
|
14158
|
-
"muse-spark-1.2-contributor-free",
|
|
14159
|
-
"union-alpha",
|
|
14160
|
-
"space-bunny-free",
|
|
14161
|
-
"nemotron-3-ultra-free",
|
|
14162
|
-
"deepseek-v4-flash-free",
|
|
14163
|
-
"mimo-v2.6-flash-free",
|
|
14164
|
-
"mimo-v2.5-free",
|
|
14165
|
-
"jev-1.13-free"
|
|
14166
|
-
]);
|
|
14167
|
-
/** Live free roster with a static fallback behind it. */
|
|
14168
|
-
var FreeCatalog = class {
|
|
14169
|
-
rows;
|
|
14170
|
-
listeners = /* @__PURE__ */ new Set();
|
|
14171
|
-
lastError;
|
|
14172
|
-
lastUpdatedAt;
|
|
14173
|
-
constructor(fallback = FALLBACK_FREE_MODEL_IDS) {
|
|
14174
|
-
this.rows = Object.freeze(buildFreeCatalog(fallback));
|
|
14175
|
-
}
|
|
14176
|
-
current() {
|
|
14177
|
-
return this.rows;
|
|
14178
|
-
}
|
|
14179
|
-
/** Why the last refresh failed, for display. Never a substitute for rows. */
|
|
14180
|
-
error() {
|
|
14181
|
-
return this.lastError;
|
|
14182
|
-
}
|
|
14183
|
-
/** When the live list last landed. */
|
|
14184
|
-
updatedAt() {
|
|
14185
|
-
return this.lastUpdatedAt;
|
|
14186
|
-
}
|
|
14187
|
-
/** The row behind a published id, or undefined. */
|
|
14188
|
-
find(modelId) {
|
|
14189
|
-
return this.rows.find((row) => row.modelId === modelId || row.id === modelId);
|
|
14190
|
-
}
|
|
14191
|
-
/** Whether anything is being served right now. */
|
|
14192
|
-
hasRows() {
|
|
14193
|
-
return this.rows.length > 0;
|
|
14194
|
-
}
|
|
14195
|
-
onChange(listener) {
|
|
14196
|
-
this.listeners.add(listener);
|
|
14197
|
-
return () => {
|
|
14198
|
-
this.listeners.delete(listener);
|
|
14199
|
-
};
|
|
14200
|
-
}
|
|
14201
|
-
/**
|
|
14202
|
-
* Re-read the listing and notify when the roster actually changed.
|
|
14203
|
-
*
|
|
14204
|
-
* A failed refresh KEEPS the current rows and records the error. Dropping to
|
|
14205
|
-
* empty here would make a network hiccup look like the models were deleted.
|
|
14206
|
-
*/
|
|
14207
|
-
async refresh(options = {}) {
|
|
14208
|
-
let next;
|
|
14209
|
-
try {
|
|
14210
|
-
next = Object.freeze(buildFreeCatalog(parseListing(await fetchFreeListing(options))));
|
|
14211
|
-
} catch (error) {
|
|
14212
|
-
this.lastError = error instanceof Error ? error.message : String(error);
|
|
14213
|
-
if (this.rows.length === 0) this.rows = Object.freeze(buildFreeCatalog(FALLBACK_FREE_MODEL_IDS));
|
|
14214
|
-
for (const listener of this.listeners) listener();
|
|
14215
|
-
return this.rows;
|
|
14216
|
-
}
|
|
14217
|
-
if (next.length === 0) {
|
|
14218
|
-
this.lastError = "the gateway listing held no免密 models";
|
|
14219
|
-
for (const listener of this.listeners) listener();
|
|
14220
|
-
return this.rows;
|
|
14221
|
-
}
|
|
14222
|
-
this.lastError = void 0;
|
|
14223
|
-
this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14224
|
-
if (next.length === this.rows.length && next.every((row, index) => row.modelId === this.rows[index]?.modelId)) return this.rows;
|
|
14225
|
-
this.rows = next;
|
|
14226
|
-
for (const listener of this.listeners) listener();
|
|
14227
|
-
return this.rows;
|
|
14228
|
-
}
|
|
14229
|
-
};
|
|
14230
|
-
//#endregion
|
|
14231
|
-
//#region src/stats.ts
|
|
14232
|
-
/**
|
|
14233
|
-
* Retained calls. Bounded on purpose: a busy week is thousands of calls, and a
|
|
14234
|
-
* dashboard that grows without limit turns into a memory leak with a chart.
|
|
14235
|
-
*/
|
|
14236
|
-
const USAGE_MAX_SAMPLES = 2e3;
|
|
14237
|
-
/**
|
|
14238
|
-
* Minimum total decoding time before a throughput figure is published.
|
|
14239
|
-
*
|
|
14240
|
-
* A one-token answer that arrives in 20ms would otherwise report a tps two
|
|
14241
|
-
* orders of magnitude above anything a real turn reaches, and the panel would
|
|
14242
|
-
* rank models by that artefact instead of by speed.
|
|
14243
|
-
*/
|
|
14244
|
-
const MIN_DECODE_MS = 120;
|
|
14245
|
-
/**
|
|
14246
|
-
* A reported token count, or null when the key is absent / not a finite
|
|
14247
|
-
* number.
|
|
14248
|
-
*
|
|
14249
|
-
* Zero is ACCEPTED: an empty turn really can cost nothing, and reporting that
|
|
14250
|
-
* as "the gateway said nothing" would make the panel lie in the other
|
|
14251
|
-
* direction.
|
|
14252
|
-
*/
|
|
14253
|
-
function count(value) {
|
|
14254
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
14255
|
-
}
|
|
14256
|
-
function zero(value) {
|
|
14257
|
-
return count(value) ?? 0;
|
|
14258
|
-
}
|
|
14259
|
-
function detailOf(value, key) {
|
|
14260
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return 0;
|
|
14261
|
-
return zero(value[key]);
|
|
14262
|
-
}
|
|
14263
|
-
/**
|
|
14264
|
-
* Read a gateway `usage` object, accepting both spellings OpenAI-compatible
|
|
14265
|
-
* gateways use (`prompt_tokens` / `input_tokens`).
|
|
14266
|
-
*
|
|
14267
|
-
* Returns undefined when the document is not a usage report at all, so callers
|
|
14268
|
-
* can tell "the gateway sent zeroes" from "there was nothing here".
|
|
14269
|
-
*/
|
|
14270
|
-
function parseUsage(value) {
|
|
14271
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
14272
|
-
const obj = value;
|
|
14273
|
-
const input = count(obj["prompt_tokens"]) ?? count(obj["input_tokens"]);
|
|
14274
|
-
const output = count(obj["completion_tokens"]) ?? count(obj["output_tokens"]);
|
|
14275
|
-
if (input === null && output === null) return void 0;
|
|
14276
|
-
return {
|
|
14277
|
-
inputTokens: input ?? 0,
|
|
14278
|
-
outputTokens: output ?? 0,
|
|
14279
|
-
reasoningTokens: detailOf(obj["completion_tokens_details"], "reasoning_tokens"),
|
|
14280
|
-
cachedTokens: detailOf(obj["prompt_tokens_details"], "cached_tokens")
|
|
14281
|
-
};
|
|
14282
|
-
}
|
|
14283
|
-
/**
|
|
14284
|
-
* True for the trailer frame a gateway sends to report usage.
|
|
14285
|
-
*
|
|
14286
|
-
* It is recognised by an EMPTY `choices` array beside a `usage` field. A frame
|
|
14287
|
-
* that also carries content is a normal chunk that happens to mention usage,
|
|
14288
|
-
* and dropping it would delete the user's answer - which is why the strip step
|
|
14289
|
-
* downstream is allowed to remove exactly these and nothing else.
|
|
14290
|
-
*/
|
|
14291
|
-
function isUsageOnlyFrame(parsed) {
|
|
14292
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
|
|
14293
|
-
const obj = parsed;
|
|
14294
|
-
if (obj["usage"] === void 0 || obj["usage"] === null) return false;
|
|
14295
|
-
const choices = obj["choices"];
|
|
14296
|
-
return Array.isArray(choices) && choices.length === 0;
|
|
15061
|
+
/** Is this id's availability known to depend on where the request leaves from? */
|
|
15062
|
+
function isRegionSensitive(modelId) {
|
|
15063
|
+
const base = baseModelId(modelId);
|
|
15064
|
+
return REGION_SENSITIVE.some((pattern) => pattern.test(base));
|
|
14297
15065
|
}
|
|
14298
|
-
/**
|
|
14299
|
-
function
|
|
14300
|
-
const
|
|
14301
|
-
const
|
|
14302
|
-
|
|
14303
|
-
return
|
|
15066
|
+
/** Title-case a bare upstream id into something a picker can show. */
|
|
15067
|
+
function displayModelName(modelId) {
|
|
15068
|
+
const base = baseModelId(modelId);
|
|
15069
|
+
const known = DISPLAY_NAMES[base];
|
|
15070
|
+
if (known !== void 0) return known;
|
|
15071
|
+
return base.replace(/[-_.]+/gu, " ").replace(/(\d)\s+/gu, "$1 ").trim().split(" ").map((word) => /^\d/u.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
15072
|
+
}
|
|
15073
|
+
/** Parse the gateway's `{"data":[{"id":…}]}` listing. */
|
|
15074
|
+
function parseListing(payload) {
|
|
15075
|
+
const rows = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : Array.isArray(payload) ? payload : [];
|
|
15076
|
+
const out = [];
|
|
15077
|
+
for (const row of rows) {
|
|
15078
|
+
const id = typeof row === "string" ? row : row?.id;
|
|
15079
|
+
if (typeof id === "string" && id !== "") out.push(id);
|
|
15080
|
+
}
|
|
15081
|
+
return out;
|
|
14304
15082
|
}
|
|
14305
15083
|
/**
|
|
14306
|
-
*
|
|
15084
|
+
* The free rows for a raw listing, in listing order.
|
|
14307
15085
|
*
|
|
14308
|
-
*
|
|
14309
|
-
* the
|
|
15086
|
+
* The prefix is what the shim routes on, so it is applied here rather than at
|
|
15087
|
+
* the call sites: one place decides the lane's id shape, and every reader gets it
|
|
15088
|
+
* the same way.
|
|
14310
15089
|
*/
|
|
14311
|
-
|
|
14312
|
-
|
|
14313
|
-
|
|
14314
|
-
|
|
14315
|
-
|
|
14316
|
-
|
|
14317
|
-
|
|
14318
|
-
|
|
14319
|
-
|
|
14320
|
-
|
|
14321
|
-
|
|
14322
|
-
|
|
14323
|
-
|
|
14324
|
-
|
|
14325
|
-
|
|
14326
|
-
|
|
14327
|
-
|
|
14328
|
-
|
|
14329
|
-
|
|
14330
|
-
|
|
14331
|
-
|
|
14332
|
-
|
|
14333
|
-
if (this.firstAt === void 0) return;
|
|
14334
|
-
const decode = at - this.firstAt;
|
|
14335
|
-
if (decode >= 0) this.decodeMs = decode;
|
|
15090
|
+
function buildFreeCatalog(ids) {
|
|
15091
|
+
const seen = /* @__PURE__ */ new Set();
|
|
15092
|
+
const rows = [];
|
|
15093
|
+
for (const raw of ids) {
|
|
15094
|
+
const id = raw.trim();
|
|
15095
|
+
if (id === "" || !isFreeLane(id)) continue;
|
|
15096
|
+
const base = baseModelId(id);
|
|
15097
|
+
if (seen.has(base)) continue;
|
|
15098
|
+
seen.add(base);
|
|
15099
|
+
const caps = capabilitiesFor(base);
|
|
15100
|
+
rows.push({
|
|
15101
|
+
id: base,
|
|
15102
|
+
modelId: `${FREE_MODEL_PREFIX}${base}`,
|
|
15103
|
+
name: displayModelName(base),
|
|
15104
|
+
wire: wireFor(base),
|
|
15105
|
+
vision: caps.vision,
|
|
15106
|
+
reasoning: caps.reasoning,
|
|
15107
|
+
contextWindow: positiveInt(caps.contextWindow) ?? FALLBACK_CAPABILITY.contextWindow,
|
|
15108
|
+
maxOutput: positiveInt(caps.maxOutput) ?? FALLBACK_CAPABILITY.maxOutput,
|
|
15109
|
+
canDisableThinking: caps.canDisableThinking,
|
|
15110
|
+
regionSensitive: isRegionSensitive(base)
|
|
15111
|
+
});
|
|
14336
15112
|
}
|
|
14337
|
-
|
|
14338
|
-
|
|
14339
|
-
|
|
14340
|
-
|
|
14341
|
-
|
|
15113
|
+
return rows;
|
|
15114
|
+
}
|
|
15115
|
+
/** One listing round. Returns the parsed JSON document. */
|
|
15116
|
+
async function fetchFreeListing(options = {}) {
|
|
15117
|
+
const controller = new AbortController();
|
|
15118
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 15e3);
|
|
15119
|
+
timer.unref?.();
|
|
15120
|
+
let callerAborted = false;
|
|
15121
|
+
const onCallerAbort = () => {
|
|
15122
|
+
callerAborted = true;
|
|
15123
|
+
controller.abort();
|
|
15124
|
+
};
|
|
15125
|
+
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
15126
|
+
try {
|
|
15127
|
+
const response = await freeFetch(`${FREE_UPSTREAM_BASE}${FREE_LISTING_PATH}`, {
|
|
15128
|
+
headers: gatewayHeaders({
|
|
15129
|
+
session: sessionForConversation(options.session),
|
|
15130
|
+
requestId: mintRequestId(),
|
|
15131
|
+
stream: false,
|
|
15132
|
+
accept: "application/json"
|
|
15133
|
+
}),
|
|
15134
|
+
signal: controller.signal
|
|
15135
|
+
});
|
|
15136
|
+
const text = await response.text();
|
|
15137
|
+
if (!response.ok) throw new Error(`the free lane answered HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
15138
|
+
return JSON.parse(text);
|
|
15139
|
+
} catch (error) {
|
|
15140
|
+
if (callerAborted || options.signal?.aborted === true) throw new Error("request aborted");
|
|
15141
|
+
if (error instanceof Error && error.name === "AbortError") throw new Error(`the free lane's listing did not answer within ${String(options.timeoutMs ?? 15e3)}ms`);
|
|
15142
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
15143
|
+
} finally {
|
|
15144
|
+
clearTimeout(timer);
|
|
15145
|
+
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
14342
15146
|
}
|
|
14343
|
-
}
|
|
15147
|
+
}
|
|
14344
15148
|
/**
|
|
14345
|
-
*
|
|
15149
|
+
* The static roster, used until the first listing lands and again whenever one
|
|
15150
|
+
* fails.
|
|
14346
15151
|
*
|
|
14347
|
-
*
|
|
14348
|
-
*
|
|
14349
|
-
*
|
|
14350
|
-
*
|
|
14351
|
-
*/
|
|
14352
|
-
|
|
14353
|
-
|
|
14354
|
-
|
|
14355
|
-
|
|
14356
|
-
|
|
14357
|
-
|
|
14358
|
-
|
|
14359
|
-
|
|
14360
|
-
|
|
14361
|
-
|
|
14362
|
-
|
|
14363
|
-
|
|
14364
|
-
|
|
14365
|
-
|
|
14366
|
-
|
|
14367
|
-
|
|
14368
|
-
|
|
14369
|
-
this.samples = [];
|
|
14370
|
-
}
|
|
14371
|
-
/** Aggregate the retained window. */
|
|
14372
|
-
summary() {
|
|
14373
|
-
const rows = /* @__PURE__ */ new Map();
|
|
14374
|
-
const turns = /* @__PURE__ */ new Map();
|
|
14375
|
-
let calls = 0;
|
|
14376
|
-
let usageReported = false;
|
|
14377
|
-
for (const call of this.samples) {
|
|
14378
|
-
calls += 1;
|
|
14379
|
-
if (call.usage !== void 0) usageReported = true;
|
|
14380
|
-
const bucket = turns.get(call.turnId) ?? {
|
|
14381
|
-
calls: 0,
|
|
14382
|
-
succeeded: 0,
|
|
14383
|
-
failed: 0
|
|
14384
|
-
};
|
|
14385
|
-
bucket.calls += 1;
|
|
14386
|
-
if (call.ok) bucket.succeeded += 1;
|
|
14387
|
-
else bucket.failed += 1;
|
|
14388
|
-
turns.set(call.turnId, bucket);
|
|
14389
|
-
const key = `${call.region}\u0000${call.model}`;
|
|
14390
|
-
let acc = rows.get(key);
|
|
14391
|
-
if (acc === void 0) {
|
|
14392
|
-
acc = {
|
|
14393
|
-
row: {
|
|
14394
|
-
region: call.region,
|
|
14395
|
-
model: call.model,
|
|
14396
|
-
calls: 0,
|
|
14397
|
-
turns: 0,
|
|
14398
|
-
failed: 0,
|
|
14399
|
-
recovered: 0,
|
|
14400
|
-
inputTokens: 0,
|
|
14401
|
-
outputTokens: 0,
|
|
14402
|
-
reasoningTokens: 0,
|
|
14403
|
-
cachedTokens: 0,
|
|
14404
|
-
tps: null,
|
|
14405
|
-
avgTtftMs: null,
|
|
14406
|
-
lastUsedAt: 0
|
|
14407
|
-
},
|
|
14408
|
-
turnIds: /* @__PURE__ */ new Set(),
|
|
14409
|
-
decodeMs: 0,
|
|
14410
|
-
ttftTotal: 0,
|
|
14411
|
-
ttftCount: 0
|
|
14412
|
-
};
|
|
14413
|
-
rows.set(key, acc);
|
|
14414
|
-
}
|
|
14415
|
-
acc.row.calls += 1;
|
|
14416
|
-
acc.turnIds.add(call.turnId);
|
|
14417
|
-
if (!call.ok) acc.row.failed += 1;
|
|
14418
|
-
if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
|
|
14419
|
-
if (call.ttftMs !== void 0) {
|
|
14420
|
-
acc.ttftTotal += call.ttftMs;
|
|
14421
|
-
acc.ttftCount += 1;
|
|
14422
|
-
}
|
|
14423
|
-
if (call.decodeMs !== void 0) acc.decodeMs += call.decodeMs;
|
|
14424
|
-
if (call.usage !== void 0) {
|
|
14425
|
-
acc.row.inputTokens += call.usage.inputTokens;
|
|
14426
|
-
acc.row.outputTokens += call.usage.outputTokens;
|
|
14427
|
-
acc.row.reasoningTokens += call.usage.reasoningTokens;
|
|
14428
|
-
acc.row.cachedTokens += call.usage.cachedTokens;
|
|
14429
|
-
}
|
|
14430
|
-
}
|
|
14431
|
-
const failedTurnIds = /* @__PURE__ */ new Set();
|
|
14432
|
-
let recoveredTurns = 0;
|
|
14433
|
-
for (const [turnId, bucket] of turns) if (bucket.failed > 0 && bucket.succeeded === 0) failedTurnIds.add(turnId);
|
|
14434
|
-
else if (bucket.failed > 0) recoveredTurns += 1;
|
|
14435
|
-
const out = [];
|
|
14436
|
-
for (const acc of rows.values()) {
|
|
14437
|
-
let recovered = 0;
|
|
14438
|
-
for (const turnId of acc.turnIds) {
|
|
14439
|
-
const bucket = turns.get(turnId);
|
|
14440
|
-
if (bucket !== void 0 && bucket.failed > 0 && bucket.succeeded > 0) recovered += 1;
|
|
14441
|
-
}
|
|
14442
|
-
out.push({
|
|
14443
|
-
...acc.row,
|
|
14444
|
-
turns: acc.turnIds.size,
|
|
14445
|
-
recovered,
|
|
14446
|
-
tps: acc.decodeMs >= MIN_DECODE_MS ? Math.round(acc.row.outputTokens / (acc.decodeMs / 1e3)) : null,
|
|
14447
|
-
avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftTotal / acc.ttftCount) : null
|
|
14448
|
-
});
|
|
14449
|
-
}
|
|
14450
|
-
out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.model.localeCompare(b.model));
|
|
14451
|
-
return {
|
|
14452
|
-
samples: this.samples.length,
|
|
14453
|
-
turns: turns.size,
|
|
14454
|
-
failedTurns: failedTurnIds.size,
|
|
14455
|
-
recoveredTurns,
|
|
14456
|
-
calls,
|
|
14457
|
-
usageReported,
|
|
14458
|
-
entries: this.samples.slice().reverse(),
|
|
14459
|
-
rows: out,
|
|
14460
|
-
accounts: this.accountRows(),
|
|
14461
|
-
days: this.days(failedTurnIds)
|
|
14462
|
-
};
|
|
14463
|
-
}
|
|
15152
|
+
* An empty picker is the one outcome a user reads as "the plugin broke", so the
|
|
15153
|
+
* lane ships with rows it can serve without ever having reached the network.
|
|
15154
|
+
* These are the ids the gateway is known to serve免密 today; a row that later
|
|
15155
|
+
* disappears costs one dead model entry, while an empty lane costs everything.
|
|
15156
|
+
*/
|
|
15157
|
+
const FALLBACK_FREE_MODEL_IDS = Object.freeze([
|
|
15158
|
+
"muse-spark-1.3-contributor-free",
|
|
15159
|
+
"muse-spark-1.2-contributor-free",
|
|
15160
|
+
"union-alpha",
|
|
15161
|
+
"space-bunny-free",
|
|
15162
|
+
"nemotron-3-ultra-free",
|
|
15163
|
+
"deepseek-v4-flash-free",
|
|
15164
|
+
"mimo-v2.6-flash-free",
|
|
15165
|
+
"mimo-v2.5-free",
|
|
15166
|
+
"jev-1.13-free"
|
|
15167
|
+
]);
|
|
15168
|
+
/** Live free roster with a static fallback behind it. */
|
|
15169
|
+
var FreeCatalog = class {
|
|
15170
|
+
rows;
|
|
15171
|
+
listeners = /* @__PURE__ */ new Set();
|
|
15172
|
+
lastError;
|
|
15173
|
+
lastUpdatedAt;
|
|
14464
15174
|
/**
|
|
14465
|
-
*
|
|
15175
|
+
* Whether this lane contributes models at all.
|
|
14466
15176
|
*
|
|
14467
|
-
*
|
|
14468
|
-
*
|
|
14469
|
-
*
|
|
14470
|
-
*
|
|
14471
|
-
*
|
|
14472
|
-
*
|
|
14473
|
-
*
|
|
15177
|
+
* OFF unless the user turns it on, which is the opposite of every other lane
|
|
15178
|
+
* here and deliberate: the 免密 lane is a shared third-party endpoint with no
|
|
15179
|
+
* credential of its own, so it cannot be "configured" — it is either silently
|
|
15180
|
+
* present or absent. Left on by default it dropped a group into the model
|
|
15181
|
+
* picker that the user never asked for and could not remove: the aggregate
|
|
15182
|
+
* switch does not govern it (it is not an aggregate provider), so the only
|
|
15183
|
+
* way out was editing the file. Reported as "关不掉,应该默认是关闭的".
|
|
14474
15184
|
*
|
|
14475
|
-
* `
|
|
14476
|
-
*
|
|
14477
|
-
* an answer to that question, it is just the other table again.
|
|
15185
|
+
* An explicit `true` is what enables it — `undefined` (a config written before
|
|
15186
|
+
* this switch existed, or a fresh install) stays off.
|
|
14478
15187
|
*/
|
|
14479
|
-
|
|
14480
|
-
|
|
14481
|
-
|
|
14482
|
-
|
|
14483
|
-
|
|
14484
|
-
|
|
14485
|
-
|
|
14486
|
-
|
|
14487
|
-
|
|
14488
|
-
|
|
14489
|
-
|
|
14490
|
-
|
|
14491
|
-
|
|
14492
|
-
failed: 0,
|
|
14493
|
-
inputTokens: 0,
|
|
14494
|
-
outputTokens: 0,
|
|
14495
|
-
cachedTokens: 0,
|
|
14496
|
-
lastUsedAt: 0,
|
|
14497
|
-
topModels: []
|
|
14498
|
-
},
|
|
14499
|
-
turnIds: /* @__PURE__ */ new Set(),
|
|
14500
|
-
modelCalls: /* @__PURE__ */ new Map()
|
|
14501
|
-
};
|
|
14502
|
-
buckets.set(key, acc);
|
|
14503
|
-
}
|
|
14504
|
-
acc.row.calls += 1;
|
|
14505
|
-
acc.turnIds.add(call.turnId);
|
|
14506
|
-
if (!call.ok) acc.row.failed += 1;
|
|
14507
|
-
if (call.at > acc.row.lastUsedAt) acc.row.lastUsedAt = call.at;
|
|
14508
|
-
acc.modelCalls.set(call.model, (acc.modelCalls.get(call.model) ?? 0) + 1);
|
|
14509
|
-
if (call.usage !== void 0) {
|
|
14510
|
-
acc.row.inputTokens += call.usage.inputTokens;
|
|
14511
|
-
acc.row.outputTokens += call.usage.outputTokens;
|
|
14512
|
-
acc.row.cachedTokens += call.usage.cachedTokens;
|
|
14513
|
-
}
|
|
14514
|
-
}
|
|
14515
|
-
const out = [];
|
|
14516
|
-
for (const acc of buckets.values()) {
|
|
14517
|
-
const topModels = [...acc.modelCalls.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 2).map(([model]) => model);
|
|
14518
|
-
out.push({
|
|
14519
|
-
...acc.row,
|
|
14520
|
-
turns: acc.turnIds.size,
|
|
14521
|
-
topModels
|
|
14522
|
-
});
|
|
14523
|
-
}
|
|
14524
|
-
out.sort((a, b) => b.calls - a.calls || b.lastUsedAt - a.lastUsedAt || a.account.localeCompare(b.account));
|
|
14525
|
-
return out;
|
|
15188
|
+
laneEnabled = false;
|
|
15189
|
+
constructor(fallback = FALLBACK_FREE_MODEL_IDS) {
|
|
15190
|
+
this.rows = Object.freeze(buildFreeCatalog(fallback));
|
|
15191
|
+
}
|
|
15192
|
+
/** Turn the lane on or off; the roster readers all consult this. */
|
|
15193
|
+
setEnabled(enabled) {
|
|
15194
|
+
if (this.laneEnabled === enabled) return;
|
|
15195
|
+
this.laneEnabled = enabled;
|
|
15196
|
+
for (const listener of this.listeners) listener();
|
|
15197
|
+
}
|
|
15198
|
+
/** Whether the lane is contributing models right now. */
|
|
15199
|
+
enabled() {
|
|
15200
|
+
return this.laneEnabled;
|
|
14526
15201
|
}
|
|
14527
15202
|
/**
|
|
14528
|
-
* The
|
|
14529
|
-
*
|
|
14530
|
-
* Sorted by key rather than insertion order because a retained window can
|
|
14531
|
-
* straddle a month boundary, and a chart whose axis jumps backwards reads as
|
|
14532
|
-
* corrupted data rather than as history.
|
|
15203
|
+
* The rows that may be OFFERED.
|
|
14533
15204
|
*
|
|
14534
|
-
*
|
|
14535
|
-
*
|
|
14536
|
-
*
|
|
14537
|
-
*
|
|
15205
|
+
* Empty when the lane is off. Callers that need the roster regardless (the
|
|
15206
|
+
* aggregate page's "free" filter, which lists what the lane COULD serve) use
|
|
15207
|
+
* {@link allRows} instead — so switching the lane off hides it from the model
|
|
15208
|
+
* picker without erasing it from the page that explains it.
|
|
14538
15209
|
*/
|
|
14539
|
-
|
|
14540
|
-
|
|
14541
|
-
for (const call of this.samples) {
|
|
14542
|
-
const key = usageDayKey(call.at);
|
|
14543
|
-
let acc = buckets.get(key);
|
|
14544
|
-
if (acc === void 0) {
|
|
14545
|
-
acc = {
|
|
14546
|
-
day: key,
|
|
14547
|
-
calls: 0,
|
|
14548
|
-
inputTokens: 0,
|
|
14549
|
-
outputTokens: 0,
|
|
14550
|
-
failedTurnIds: /* @__PURE__ */ new Set()
|
|
14551
|
-
};
|
|
14552
|
-
buckets.set(key, acc);
|
|
14553
|
-
}
|
|
14554
|
-
acc.calls += 1;
|
|
14555
|
-
acc.inputTokens += call.usage?.inputTokens ?? 0;
|
|
14556
|
-
acc.outputTokens += call.usage?.outputTokens ?? 0;
|
|
14557
|
-
if (failedTurnIds.has(call.turnId)) acc.failedTurnIds.add(call.turnId);
|
|
14558
|
-
}
|
|
14559
|
-
return [...buckets.values()].sort((a, b) => a.day.localeCompare(b.day)).slice(-14).map((acc) => ({
|
|
14560
|
-
day: acc.day,
|
|
14561
|
-
calls: acc.calls,
|
|
14562
|
-
failedTurns: acc.failedTurnIds.size,
|
|
14563
|
-
inputTokens: acc.inputTokens,
|
|
14564
|
-
outputTokens: acc.outputTokens
|
|
14565
|
-
}));
|
|
15210
|
+
current() {
|
|
15211
|
+
return this.laneEnabled ? this.rows : [];
|
|
14566
15212
|
}
|
|
14567
|
-
|
|
14568
|
-
|
|
14569
|
-
|
|
14570
|
-
|
|
14571
|
-
|
|
14572
|
-
|
|
14573
|
-
|
|
14574
|
-
|
|
14575
|
-
|
|
14576
|
-
|
|
14577
|
-
|
|
14578
|
-
|
|
14579
|
-
|
|
14580
|
-
|
|
14581
|
-
|
|
15213
|
+
/** Every row the lane knows about, offered or not. */
|
|
15214
|
+
allRows() {
|
|
15215
|
+
return this.rows;
|
|
15216
|
+
}
|
|
15217
|
+
/** Why the last refresh failed, for display. Never a substitute for rows. */
|
|
15218
|
+
error() {
|
|
15219
|
+
return this.lastError;
|
|
15220
|
+
}
|
|
15221
|
+
/** When the live list last landed. */
|
|
15222
|
+
updatedAt() {
|
|
15223
|
+
return this.lastUpdatedAt;
|
|
15224
|
+
}
|
|
15225
|
+
/** The row behind a published id, or undefined. */
|
|
15226
|
+
find(modelId) {
|
|
15227
|
+
return this.rows.find((row) => row.modelId === modelId || row.id === modelId);
|
|
15228
|
+
}
|
|
15229
|
+
/** Whether anything is being served right now. */
|
|
15230
|
+
hasRows() {
|
|
15231
|
+
return this.rows.length > 0;
|
|
15232
|
+
}
|
|
15233
|
+
onChange(listener) {
|
|
15234
|
+
this.listeners.add(listener);
|
|
15235
|
+
return () => {
|
|
15236
|
+
this.listeners.delete(listener);
|
|
15237
|
+
};
|
|
15238
|
+
}
|
|
15239
|
+
/**
|
|
15240
|
+
* Re-read the listing and notify when the roster actually changed.
|
|
15241
|
+
*
|
|
15242
|
+
* A failed refresh KEEPS the current rows and records the error. Dropping to
|
|
15243
|
+
* empty here would make a network hiccup look like the models were deleted.
|
|
15244
|
+
*/
|
|
15245
|
+
async refresh(options = {}) {
|
|
15246
|
+
let next;
|
|
14582
15247
|
try {
|
|
14583
|
-
|
|
14584
|
-
} catch {
|
|
14585
|
-
|
|
15248
|
+
next = Object.freeze(buildFreeCatalog(parseListing(await fetchFreeListing(options))));
|
|
15249
|
+
} catch (error) {
|
|
15250
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
15251
|
+
if (this.rows.length === 0) this.rows = Object.freeze(buildFreeCatalog(FALLBACK_FREE_MODEL_IDS));
|
|
15252
|
+
for (const listener of this.listeners) listener();
|
|
15253
|
+
return this.rows;
|
|
14586
15254
|
}
|
|
14587
|
-
if (
|
|
14588
|
-
|
|
14589
|
-
|
|
14590
|
-
|
|
14591
|
-
|
|
14592
|
-
|
|
14593
|
-
|
|
15255
|
+
if (next.length === 0) {
|
|
15256
|
+
this.lastError = "the gateway listing held no免密 models";
|
|
15257
|
+
for (const listener of this.listeners) listener();
|
|
15258
|
+
return this.rows;
|
|
15259
|
+
}
|
|
15260
|
+
this.lastError = void 0;
|
|
15261
|
+
this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15262
|
+
if (next.length === this.rows.length && next.every((row, index) => row.modelId === this.rows[index]?.modelId)) return this.rows;
|
|
15263
|
+
this.rows = next;
|
|
15264
|
+
for (const listener of this.listeners) listener();
|
|
15265
|
+
return this.rows;
|
|
14594
15266
|
}
|
|
14595
|
-
}
|
|
15267
|
+
};
|
|
14596
15268
|
//#endregion
|
|
14597
15269
|
//#region src/free-lane.ts
|
|
14598
15270
|
/**
|
|
@@ -14703,6 +15375,7 @@ function createFreeLaneSource(catalog, logger, usageRecorder) {
|
|
|
14703
15375
|
} finally {
|
|
14704
15376
|
tracker.finish(Date.now());
|
|
14705
15377
|
usageRecorder?.record({
|
|
15378
|
+
channel: "free",
|
|
14706
15379
|
region: "free",
|
|
14707
15380
|
model: modelId,
|
|
14708
15381
|
account: "free",
|
|
@@ -15051,6 +15724,71 @@ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
|
|
|
15051
15724
|
};
|
|
15052
15725
|
}
|
|
15053
15726
|
/**
|
|
15727
|
+
* Pull the `expertId` out of a task's `jump_url`.
|
|
15728
|
+
*
|
|
15729
|
+
* The "体验XX专家" tasks (#98) name an expert that is NOT in the marketplace
|
|
15730
|
+
* listing — the reporter翻遍了 marketExpertList in both `agent` and `team`
|
|
15731
|
+
* types and got zero hits — so the only place the id appears is the deeplink
|
|
15732
|
+
* embedded in the task itself:
|
|
15733
|
+
*
|
|
15734
|
+
* https://www.workbuddy.cn/work/launch/?deeplink=workbuddy%3A%2F%2Fexperts%3FexpertId%3Dex_GDl31WahyIH0&buddy_type=workbuddy
|
|
15735
|
+
*
|
|
15736
|
+
* The deeplink is percent-encoded, and it has to be: the `?` and `&` inside it
|
|
15737
|
+
* would otherwise end the outer query. So the parse is two steps — decode the
|
|
15738
|
+
* `deeplink` parameter, then read `expertId` off the scheme it decodes to — and
|
|
15739
|
+
* NOT a regex over the whole URL, which would also match the outer `expertId`
|
|
15740
|
+
* spelling in a future URL shape and silently pick the wrong one.
|
|
15741
|
+
*
|
|
15742
|
+
* Returns undefined for the tasks whose deeplink carries no id at all
|
|
15743
|
+
* (`WorkBuddy_Valorant` points at `workbuddy://expert` with no query), which is
|
|
15744
|
+
* the honest answer: those need a different source and are not guessed here.
|
|
15745
|
+
*/
|
|
15746
|
+
function expertIdFromJumpUrl(jumpUrl) {
|
|
15747
|
+
if (jumpUrl === "") return void 0;
|
|
15748
|
+
/** Read `expertId` off any URL-ish string, decoded or not. */
|
|
15749
|
+
const readId = (value) => {
|
|
15750
|
+
let query = value;
|
|
15751
|
+
try {
|
|
15752
|
+
const parsed = new URL(value);
|
|
15753
|
+
query = parsed.search === "" ? value : parsed.search;
|
|
15754
|
+
} catch {
|
|
15755
|
+
query = value;
|
|
15756
|
+
}
|
|
15757
|
+
const marker = query.indexOf("expertId=");
|
|
15758
|
+
if (marker === -1) return void 0;
|
|
15759
|
+
const rest = query.slice(marker + 9);
|
|
15760
|
+
const end = rest.search(/[&#]/u);
|
|
15761
|
+
const id = (end === -1 ? rest : rest.slice(0, end)).trim();
|
|
15762
|
+
return id === "" ? void 0 : id;
|
|
15763
|
+
};
|
|
15764
|
+
let source = jumpUrl;
|
|
15765
|
+
try {
|
|
15766
|
+
const raw = new URL(jumpUrl).searchParams.get("deeplink");
|
|
15767
|
+
if (raw !== null && raw !== "") source = raw;
|
|
15768
|
+
} catch {
|
|
15769
|
+
source = jumpUrl;
|
|
15770
|
+
}
|
|
15771
|
+
return readId(source);
|
|
15772
|
+
}
|
|
15773
|
+
/**
|
|
15774
|
+
* Build a summon record for an expert the marketplace does not list.
|
|
15775
|
+
*
|
|
15776
|
+
* Everything the chains need beyond the id is cosmetic — the scorer keys on
|
|
15777
|
+
* `expertId` — so the title from the task is reused as the display name rather
|
|
15778
|
+
* than inventing one. `version` is left empty, which every consumer already
|
|
15779
|
+
* defaults to `1.0.0`.
|
|
15780
|
+
*/
|
|
15781
|
+
function expertFromTask(expertId, title) {
|
|
15782
|
+
return {
|
|
15783
|
+
expertId,
|
|
15784
|
+
expertType: "agent",
|
|
15785
|
+
displayName: title,
|
|
15786
|
+
profession: title,
|
|
15787
|
+
version: "",
|
|
15788
|
+
categories: []
|
|
15789
|
+
};
|
|
15790
|
+
}
|
|
15791
|
+
/**
|
|
15054
15792
|
* The three "summon an expert" events (`expert_summon_click` and friends).
|
|
15055
15793
|
*
|
|
15056
15794
|
* Paid before the conversation, in the order the app emits them.
|
|
@@ -15173,7 +15911,12 @@ const EVENT_CHAIN_BUILDERS = {
|
|
|
15173
15911
|
skill_1: true,
|
|
15174
15912
|
expert_5: true,
|
|
15175
15913
|
Expert_team_use_3: true,
|
|
15176
|
-
Expert_lighthouse: true
|
|
15914
|
+
Expert_lighthouse: true,
|
|
15915
|
+
expert_use_maidehao: true,
|
|
15916
|
+
expert_use_douyinyin: true,
|
|
15917
|
+
expert_use_shangjia_xuanchuan: true,
|
|
15918
|
+
expert_use_bowangwang: true,
|
|
15919
|
+
WorkBuddy_Valorant: true
|
|
15177
15920
|
};
|
|
15178
15921
|
const AUTOMATION_TICK_MS = 6e4;
|
|
15179
15922
|
/** The jobs in the order a tick runs them: report before tasks, always. */
|
|
@@ -15307,10 +16050,35 @@ function isFireHour(now, hours) {
|
|
|
15307
16050
|
const { hour } = zonedParts(now);
|
|
15308
16051
|
return hours.includes(Number(hour));
|
|
15309
16052
|
}
|
|
15310
|
-
/**
|
|
15311
|
-
|
|
16053
|
+
/**
|
|
16054
|
+
* Which account classes each job may touch.
|
|
16055
|
+
*
|
|
16056
|
+
* This replaced a single `global → false`, which was right about the growth
|
|
16057
|
+
* system and wrong as a blanket: it excluded a global account from EVERY job,
|
|
16058
|
+
* including the ones that are idempotent, cheap, and pay credits regardless of
|
|
16059
|
+
* region. The blanket also made the rule unfalsifiable — "global does nothing"
|
|
16060
|
+
* cannot be checked, whereas "the checkin job tries global accounts and here is
|
|
16061
|
+
* what came back" can.
|
|
16062
|
+
*
|
|
16063
|
+
* So the scope is per job, and the default for a job that is not named is `cn`:
|
|
16064
|
+
* a new job must opt in explicitly rather than inherit an accident.
|
|
16065
|
+
*/
|
|
16066
|
+
const JOB_REGION_SCOPE = {
|
|
16067
|
+
report: ["cn"],
|
|
16068
|
+
tasks: ["cn"],
|
|
16069
|
+
streak: ["cn"],
|
|
16070
|
+
travel: ["cn"],
|
|
16071
|
+
compensation: ["cn"],
|
|
16072
|
+
checkin: ["cn", "global"]
|
|
16073
|
+
};
|
|
16074
|
+
/** Whether `job` may run against `region`; unnamed jobs stay CN. */
|
|
16075
|
+
function jobAllowsRegion(job, region) {
|
|
16076
|
+
return (JOB_REGION_SCOPE[job] ?? ["cn"]).includes(region);
|
|
16077
|
+
}
|
|
16078
|
+
/** Whether this account may be used for a job: not cooling, and right region. */
|
|
16079
|
+
function eligible(account, now, job) {
|
|
15312
16080
|
if (account.cooldownUntilMs > now) return false;
|
|
15313
|
-
if (regionOf(account.credential.domain)
|
|
16081
|
+
if (job !== void 0 && !jobAllowsRegion(job, regionOf(account.credential.domain))) return false;
|
|
15314
16082
|
return true;
|
|
15315
16083
|
}
|
|
15316
16084
|
const sleep$1 = (ms) => new Promise((resolve) => {
|
|
@@ -15381,6 +16149,16 @@ var WorkBuddyScheduler = class {
|
|
|
15381
16149
|
/** Host hooks that persist the ledger across restarts. */
|
|
15382
16150
|
loadEarnings;
|
|
15383
16151
|
saveEarningsFn;
|
|
16152
|
+
/**
|
|
16153
|
+
* The most recent ledger write that may still be in flight.
|
|
16154
|
+
*
|
|
16155
|
+
* `persistEarnings` is synchronous from its callers' point of view, but the
|
|
16156
|
+
* host's settings write is not: it is an async document update. Keeping the
|
|
16157
|
+
* promise here is what lets {@link flushEarnings} wait for the write that
|
|
16158
|
+
* matters — the last one before a restart — without making every counter
|
|
16159
|
+
* update await disk.
|
|
16160
|
+
*/
|
|
16161
|
+
pendingEarningsWrite = Promise.resolve();
|
|
15384
16162
|
constructor(pool, client, options = {}) {
|
|
15385
16163
|
this.pool = pool;
|
|
15386
16164
|
this.client = client;
|
|
@@ -15417,6 +16195,18 @@ var WorkBuddyScheduler = class {
|
|
|
15417
16195
|
this.saveEarningsFn = save;
|
|
15418
16196
|
}
|
|
15419
16197
|
/**
|
|
16198
|
+
* Wait for the ledger write that is (or was last) in flight.
|
|
16199
|
+
*
|
|
16200
|
+
* Called at the END of a task pass, right after the last claim, so the rewards
|
|
16201
|
+
* that were just collected are on disk before a restart can lose them. It is
|
|
16202
|
+
* deliberately NOT awaited on every counter bump: the settings document write
|
|
16203
|
+
* is the slow part, and paying it per account would slow the pass for no
|
|
16204
|
+
* benefit while the process is still alive.
|
|
16205
|
+
*/
|
|
16206
|
+
async flushEarnings() {
|
|
16207
|
+
await this.pendingEarningsWrite;
|
|
16208
|
+
}
|
|
16209
|
+
/**
|
|
15420
16210
|
* Fold a previously persisted ledger back in, when it belongs to today.
|
|
15421
16211
|
*
|
|
15422
16212
|
* Used after the settings document becomes readable, which happens after
|
|
@@ -15686,7 +16476,7 @@ var WorkBuddyScheduler = class {
|
|
|
15686
16476
|
compensationCredit: (existing?.compensationCredit ?? 0) + compensationCredit,
|
|
15687
16477
|
date: today
|
|
15688
16478
|
});
|
|
15689
|
-
this.persistEarnings();
|
|
16479
|
+
this.pendingEarningsWrite = this.persistEarnings();
|
|
15690
16480
|
}
|
|
15691
16481
|
/** Clear the per-account counters when the local day changes. */
|
|
15692
16482
|
rollEarnings(today) {
|
|
@@ -15702,9 +16492,18 @@ var WorkBuddyScheduler = class {
|
|
|
15702
16492
|
* Best effort on purpose: a failed save must never abort a run that has
|
|
15703
16493
|
* already collected rewards, and the in-memory ledger keeps serving the card
|
|
15704
16494
|
* for the rest of the session either way.
|
|
16495
|
+
*
|
|
16496
|
+
* The returned promise settles when the write has landed (or failed). Callers
|
|
16497
|
+
* that are about to END a run await it: reported in #98, a task pass that
|
|
16498
|
+
* claimed 21 tasks had its process restarted before the volatile settings
|
|
16499
|
+
* write reached disk, so `automationEarnings` still read `credit: 0` while
|
|
16500
|
+
* the upstream had really paid out. Fire-and-forget is fine for a counter the
|
|
16501
|
+
* card will re-read in this process; it is not fine for the last write before
|
|
16502
|
+
* exit.
|
|
15705
16503
|
*/
|
|
15706
16504
|
persistEarnings() {
|
|
15707
|
-
|
|
16505
|
+
const save = this.saveEarningsFn;
|
|
16506
|
+
if (save === void 0) return Promise.resolve();
|
|
15708
16507
|
const accounts = {};
|
|
15709
16508
|
for (const [id, entry] of this.earnings) accounts[id] = entry;
|
|
15710
16509
|
const ledger = {
|
|
@@ -15712,12 +16511,14 @@ var WorkBuddyScheduler = class {
|
|
|
15712
16511
|
accounts
|
|
15713
16512
|
};
|
|
15714
16513
|
try {
|
|
15715
|
-
const saved =
|
|
15716
|
-
if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
|
|
16514
|
+
const saved = save(ledger);
|
|
16515
|
+
if (saved !== void 0 && typeof saved.then === "function") return Promise.resolve(saved).then(void 0, (error) => {
|
|
15717
16516
|
this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
|
|
15718
16517
|
});
|
|
16518
|
+
return Promise.resolve();
|
|
15719
16519
|
} catch (error) {
|
|
15720
16520
|
this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
|
|
16521
|
+
return Promise.resolve();
|
|
15721
16522
|
}
|
|
15722
16523
|
}
|
|
15723
16524
|
/**
|
|
@@ -15744,8 +16545,22 @@ var WorkBuddyScheduler = class {
|
|
|
15744
16545
|
let energy = 0;
|
|
15745
16546
|
let claimed = 0;
|
|
15746
16547
|
const detail = [];
|
|
16548
|
+
/**
|
|
16549
|
+
* Every item this run considered, with a status attached.
|
|
16550
|
+
*
|
|
16551
|
+
* `detail` stays as the human-readable log line; this is the same work in a
|
|
16552
|
+
* shape the card can GROUP and COUNT, so "3 tasks unclaimed — locked" is
|
|
16553
|
+
* distinguishable from "0 tasks, everything already collected".
|
|
16554
|
+
*/
|
|
16555
|
+
const items = [];
|
|
15747
16556
|
let progressNote;
|
|
15748
|
-
|
|
16557
|
+
try {
|
|
16558
|
+
const revived = await this.pool.reviveBenched((credential) => this.client.fetchCredits(credential));
|
|
16559
|
+
for (const account of revived) detail.push(`复活 ${account.label}`);
|
|
16560
|
+
} catch (error) {
|
|
16561
|
+
this.logger.warn?.("automation: could not probe benched accounts:", error);
|
|
16562
|
+
}
|
|
16563
|
+
const accounts = this.accountsInOrder(kind);
|
|
15749
16564
|
if (kind === "tasks") await this.sendEventChains(accounts);
|
|
15750
16565
|
for (let index = 0; index < accounts.length; index++) {
|
|
15751
16566
|
if (this.stopped) break;
|
|
@@ -15753,12 +16568,22 @@ var WorkBuddyScheduler = class {
|
|
|
15753
16568
|
if (account === void 0) continue;
|
|
15754
16569
|
try {
|
|
15755
16570
|
switch (kind) {
|
|
15756
|
-
case "checkin":
|
|
16571
|
+
case "checkin": {
|
|
16572
|
+
let alreadyDone = false;
|
|
16573
|
+
try {
|
|
16574
|
+
alreadyDone = (await this.client.fetchCheckinStatus(account.credential)).todayCheckedIn === true;
|
|
16575
|
+
} catch (error) {
|
|
16576
|
+
this.logger.warn?.(`automation checkin ${account.label}: status read failed, claiming anyway:`, error);
|
|
16577
|
+
}
|
|
16578
|
+
if (alreadyDone) {
|
|
16579
|
+
this.logger.info?.(`automation checkin ${account.label}: already done today (read, not claimed)`);
|
|
16580
|
+
break;
|
|
16581
|
+
}
|
|
15757
16582
|
try {
|
|
15758
16583
|
const claim = await this.client.claimDailyCheckin(account.credential);
|
|
15759
16584
|
this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
|
|
15760
16585
|
if (claim.credit > 0) {
|
|
15761
|
-
|
|
16586
|
+
detail.push(`签到 +${claim.credit}`);
|
|
15762
16587
|
this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
|
|
15763
16588
|
}
|
|
15764
16589
|
} catch (error) {
|
|
@@ -15766,6 +16591,7 @@ var WorkBuddyScheduler = class {
|
|
|
15766
16591
|
this.logger.info?.(`automation checkin ${account.label}: already done today`);
|
|
15767
16592
|
}
|
|
15768
16593
|
break;
|
|
16594
|
+
}
|
|
15769
16595
|
case "report": {
|
|
15770
16596
|
const days = await this.reportOne(account);
|
|
15771
16597
|
this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
|
|
@@ -15777,6 +16603,7 @@ var WorkBuddyScheduler = class {
|
|
|
15777
16603
|
energy += result.energy;
|
|
15778
16604
|
claimed += result.claimed;
|
|
15779
16605
|
detail.push(...result.titles);
|
|
16606
|
+
items.push(...result.items);
|
|
15780
16607
|
this.claimableSeen += result.claimableCount;
|
|
15781
16608
|
this.recordEarnings(account.id, today, {
|
|
15782
16609
|
credit: result.credit,
|
|
@@ -15824,8 +16651,11 @@ var WorkBuddyScheduler = class {
|
|
|
15824
16651
|
state.claimed = claimed;
|
|
15825
16652
|
state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
|
|
15826
16653
|
state.detail = detail;
|
|
15827
|
-
state.
|
|
16654
|
+
state.items = items;
|
|
16655
|
+
if (progressNote === void 0) delete state.progress;
|
|
16656
|
+
else state.progress = progressNote;
|
|
15828
16657
|
this.logger.info?.(`automation ${accountWord}: ${state.message}`);
|
|
16658
|
+
if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
|
|
15829
16659
|
}
|
|
15830
16660
|
/** Compose the one-line summary shown on the card. */
|
|
15831
16661
|
summarise(kind, ok, failed, claimed, credit, energy) {
|
|
@@ -15837,12 +16667,16 @@ var WorkBuddyScheduler = class {
|
|
|
15837
16667
|
*
|
|
15838
16668
|
* Disabled accounts are excluded here rather than filtered by the caller so a
|
|
15839
16669
|
* card switch takes effect on the next pass without any event plumbing.
|
|
16670
|
+
*
|
|
16671
|
+
* `job` narrows the set by region scope: the growth jobs are CN-only while
|
|
16672
|
+
* check-in runs on both gateways, so eligibility cannot be decided without
|
|
16673
|
+
* knowing which job is about to run.
|
|
15840
16674
|
*/
|
|
15841
|
-
accountsInOrder() {
|
|
16675
|
+
accountsInOrder(job) {
|
|
15842
16676
|
const now = Date.now();
|
|
15843
16677
|
return this.pool.list().filter((account) => {
|
|
15844
16678
|
if (this.pool.isDisabled(account.id)) return false;
|
|
15845
|
-
return eligible(account, now);
|
|
16679
|
+
return eligible(account, now, job);
|
|
15846
16680
|
});
|
|
15847
16681
|
}
|
|
15848
16682
|
/**
|
|
@@ -15948,10 +16782,88 @@ var WorkBuddyScheduler = class {
|
|
|
15948
16782
|
case "expert_5": return this.expertChains(credential, "agent", task);
|
|
15949
16783
|
case "Expert_team_use_3": return this.expertChains(credential, "team", task);
|
|
15950
16784
|
case "Expert_lighthouse": return this.lighthouseChains(credential);
|
|
16785
|
+
case "expert_use_maidehao":
|
|
16786
|
+
case "expert_use_douyinyin":
|
|
16787
|
+
case "expert_use_shangjia_xuanchuan":
|
|
16788
|
+
case "expert_use_bowangwang":
|
|
16789
|
+
case "WorkBuddy_Valorant": return this.taskExpertChains(credential, task);
|
|
15951
16790
|
default: return [];
|
|
15952
16791
|
}
|
|
15953
16792
|
}
|
|
15954
16793
|
/**
|
|
16794
|
+
* Summon-and-use one expert NAMED BY THE TASK.
|
|
16795
|
+
*
|
|
16796
|
+
* Structurally `expertChains` minus the marketplace: the expert id is read
|
|
16797
|
+
* from the task's deeplink instead of by browsing `marketExpertList`, because
|
|
16798
|
+
* this family's experts are not listed there at all (#98 measured 500 entries
|
|
16799
|
+
* across both `agent` and `team`, zero hits).
|
|
16800
|
+
*
|
|
16801
|
+
* Returns `[]` — not a throw — when the task carries no usable id: the caller
|
|
16802
|
+
* logs "could not be built" only for exceptions, and a task shape this code
|
|
16803
|
+
* does not understand is information, not a fault.
|
|
16804
|
+
*/
|
|
16805
|
+
/**
|
|
16806
|
+
* Find the expert a task is about, by matching its title against the
|
|
16807
|
+
* marketplace.
|
|
16808
|
+
*
|
|
16809
|
+
* The marketplace listing is ~500 entries across `agent` and `team`, and this
|
|
16810
|
+
* only runs for a task whose deeplink carried no id — at most a handful per
|
|
16811
|
+
* run — so the cost is acceptable. It is not cached because a freshly
|
|
16812
|
+
* published expert must become findable without a restart.
|
|
16813
|
+
*
|
|
16814
|
+
* Matching rules, all of them strict on purpose:
|
|
16815
|
+
* - the task's title must appear IN the expert's name or profession, or the
|
|
16816
|
+
* expert's display name must appear in the title (both directions, since
|
|
16817
|
+
* the board and the marketplace word things differently);
|
|
16818
|
+
* - comparison ignores case, whitespace and the punctuation vendors sprinkle
|
|
16819
|
+
* through names;
|
|
16820
|
+
* - several matches means NO answer. Summoning the wrong agent scores 0, so a
|
|
16821
|
+
* guess costs an upstream call and still fails — worse than declining.
|
|
16822
|
+
*/
|
|
16823
|
+
async expertIdByKeyword(credential, task) {
|
|
16824
|
+
const normalise = (value) => value.toLowerCase().replace(/[\s_\-·::,,.。()()【】[\]「」]/gu, "");
|
|
16825
|
+
const wanted = normalise(task.title);
|
|
16826
|
+
if (wanted === "") return void 0;
|
|
16827
|
+
let candidates;
|
|
16828
|
+
try {
|
|
16829
|
+
const [agents, teams] = await Promise.all([this.client.marketExpertList(credential, "agent"), this.client.marketExpertList(credential, "team")]);
|
|
16830
|
+
candidates = [...agents, ...teams];
|
|
16831
|
+
} catch (error) {
|
|
16832
|
+
this.logger.warn?.(`automation events ${task.taskCode}: marketplace lookup failed:`, error);
|
|
16833
|
+
return;
|
|
16834
|
+
}
|
|
16835
|
+
const matches = candidates.filter((candidate) => {
|
|
16836
|
+
const name = normalise(candidate.displayName ?? "");
|
|
16837
|
+
const profession = normalise(candidate.profession ?? "");
|
|
16838
|
+
if (name === "" && profession === "") return false;
|
|
16839
|
+
return name !== "" && (name.includes(wanted) || wanted.includes(name)) || profession !== "" && (profession.includes(wanted) || wanted.includes(profession));
|
|
16840
|
+
});
|
|
16841
|
+
if (matches.length !== 1) {
|
|
16842
|
+
if (matches.length > 1) this.logger.info?.(`automation events ${task.taskCode}: ${String(matches.length)} marketplace experts match "${task.title}", not guessing`);
|
|
16843
|
+
return;
|
|
16844
|
+
}
|
|
16845
|
+
const found = matches[0];
|
|
16846
|
+
if (found === void 0) return void 0;
|
|
16847
|
+
this.logger.info?.(`automation events ${task.taskCode}: resolved expertId ${found.expertId} by keyword "${task.title}"`);
|
|
16848
|
+
return found.expertId;
|
|
16849
|
+
}
|
|
16850
|
+
async taskExpertChains(credential, task) {
|
|
16851
|
+
const expertId = expertIdFromJumpUrl(task.jumpUrl) ?? await this.expertIdByKeyword(credential, task);
|
|
16852
|
+
if (expertId === void 0) {
|
|
16853
|
+
this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url and no marketplace match, skipping`);
|
|
16854
|
+
return [];
|
|
16855
|
+
}
|
|
16856
|
+
const expert = expertFromTask(expertId, task.title);
|
|
16857
|
+
await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
|
|
16858
|
+
if (this.delayMs > 0 && !this.stopped) await sleep$1(this.delayMs);
|
|
16859
|
+
const chat = await this.client.openConversation(credential, expert.expertId);
|
|
16860
|
+
if (chat === void 0) throw new Error(`${task.taskCode}: no server conversation`);
|
|
16861
|
+
return [{
|
|
16862
|
+
transport: "desktop",
|
|
16863
|
+
events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
|
|
16864
|
+
}];
|
|
16865
|
+
}
|
|
16866
|
+
/**
|
|
15955
16867
|
* The summon-and-use chains for the expert tasks.
|
|
15956
16868
|
*
|
|
15957
16869
|
* Two steps per expert, and both are load-bearing: the summon events alone are
|
|
@@ -16023,21 +16935,41 @@ var WorkBuddyScheduler = class {
|
|
|
16023
16935
|
const credential = account.credential;
|
|
16024
16936
|
const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
|
|
16025
16937
|
if (open.length > 0) await this.client.acceptTasks(credential, open);
|
|
16026
|
-
const
|
|
16938
|
+
const refreshed = await this.client.listTasks(credential);
|
|
16939
|
+
const claimable = refreshed.filter((task) => task.claimable && !task.locked);
|
|
16027
16940
|
let credit = 0;
|
|
16028
16941
|
let energy = 0;
|
|
16029
16942
|
let claimed = 0;
|
|
16030
16943
|
const titles = [];
|
|
16944
|
+
const items = [];
|
|
16945
|
+
for (const task of refreshed) if (task.locked === true || task.claimable !== true) items.push({
|
|
16946
|
+
title: task.title === "" ? task.taskCode : task.title,
|
|
16947
|
+
status: "skipped",
|
|
16948
|
+
note: task.locked === true ? "locked" : task.acceptStatus === "not_accepted" ? "not accepted" : "not scored yet"
|
|
16949
|
+
});
|
|
16031
16950
|
for (const task of claimable) {
|
|
16032
16951
|
if (this.stopped) break;
|
|
16033
16952
|
const reward = await this.client.claimTaskReward(credential, task.taskCode);
|
|
16034
16953
|
credit += reward.credit;
|
|
16035
16954
|
energy += reward.energy;
|
|
16955
|
+
const label = task.title === "" ? task.taskCode : task.title;
|
|
16036
16956
|
if (reward.credit > 0 || reward.energy > 0) {
|
|
16037
16957
|
claimed++;
|
|
16038
16958
|
titles.push(task.title);
|
|
16959
|
+
items.push({
|
|
16960
|
+
title: label,
|
|
16961
|
+
status: "claimed",
|
|
16962
|
+
...reward.credit > 0 ? { credit: reward.credit } : {},
|
|
16963
|
+
...reward.energy > 0 ? { energy: reward.energy } : {}
|
|
16964
|
+
});
|
|
16039
16965
|
this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
|
|
16040
|
-
} else
|
|
16966
|
+
} else {
|
|
16967
|
+
items.push({
|
|
16968
|
+
title: label,
|
|
16969
|
+
status: "already"
|
|
16970
|
+
});
|
|
16971
|
+
this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
|
|
16972
|
+
}
|
|
16041
16973
|
if (this.delayMs > 0 && !this.stopped) await sleep$1(this.delayMs);
|
|
16042
16974
|
}
|
|
16043
16975
|
return {
|
|
@@ -16045,7 +16977,8 @@ var WorkBuddyScheduler = class {
|
|
|
16045
16977
|
credit,
|
|
16046
16978
|
energy,
|
|
16047
16979
|
claimableCount: claimable.length,
|
|
16048
|
-
titles
|
|
16980
|
+
titles,
|
|
16981
|
+
items
|
|
16049
16982
|
};
|
|
16050
16983
|
}
|
|
16051
16984
|
/**
|
|
@@ -16214,6 +17147,7 @@ const PROVIDER_NOTES_ZH = Object.freeze({
|
|
|
16214
17147
|
"kimi_code": "Kimi Code 订阅,模型 id 取自 9router 的 kimi 注册表",
|
|
16215
17148
|
"lightning": "Lightning AI 的 Model APIs 网关",
|
|
16216
17149
|
"llamacpp": "本地服务器,模型列表取决于用户已加载的模型",
|
|
17150
|
+
"custom_openai": "自定义 OpenAI 兼容端点:vLLM / LiteLLM / one-api / 自建网关,填 base URL 和 key 后拉取模型",
|
|
16217
17151
|
"llm7": "default/fast/pro 别名被固定,只留流式工具模型",
|
|
16218
17152
|
"lmstudio": "本地服务器,模型列表取决于用户已加载的模型",
|
|
16219
17153
|
"minimax": "推理内容从正文流中单独拆出,语音与图像 id 已剔除",
|
|
@@ -16573,6 +17507,24 @@ function parseAggregatePatch(body) {
|
|
|
16573
17507
|
};
|
|
16574
17508
|
if (typeof body["enabled"] === "boolean") patch.enabled = body["enabled"];
|
|
16575
17509
|
if (typeof body["apiKey"] === "string") patch.apiKey = body["apiKey"];
|
|
17510
|
+
if (Array.isArray(body["apiKeys"])) {
|
|
17511
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17512
|
+
const apiKeys = [];
|
|
17513
|
+
for (const raw of body["apiKeys"]) {
|
|
17514
|
+
if (typeof raw !== "string") continue;
|
|
17515
|
+
const key = raw.trim();
|
|
17516
|
+
if (key === "" || seen.has(key)) continue;
|
|
17517
|
+
seen.add(key);
|
|
17518
|
+
apiKeys.push(key);
|
|
17519
|
+
}
|
|
17520
|
+
patch.apiKeys = apiKeys;
|
|
17521
|
+
}
|
|
17522
|
+
if (typeof body["distribution"] === "string") {
|
|
17523
|
+
const strategy = body["distribution"].trim();
|
|
17524
|
+
if (!isDistributionStrategy(strategy)) return void 0;
|
|
17525
|
+
patch.distribution = strategy;
|
|
17526
|
+
}
|
|
17527
|
+
if (typeof body["appendApiKey"] === "string") patch.appendApiKey = body["appendApiKey"];
|
|
16576
17528
|
if (typeof body["baseUrl"] === "string") patch.baseUrl = body["baseUrl"];
|
|
16577
17529
|
if (Array.isArray(body["models"])) {
|
|
16578
17530
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -16634,7 +17586,9 @@ function aggregateStatusOf(store, models) {
|
|
|
16634
17586
|
models: exposed,
|
|
16635
17587
|
availableModels: universe,
|
|
16636
17588
|
discovered: (setting.discovered?.length ?? 0) > 0,
|
|
16637
|
-
...setting.addedAt === void 0 ? {} : { addedAt: setting.addedAt }
|
|
17589
|
+
...setting.addedAt === void 0 ? {} : { addedAt: setting.addedAt },
|
|
17590
|
+
keyCount: providerApiKeys(setting).length,
|
|
17591
|
+
...setting.distribution === void 0 ? {} : { distribution: setting.distribution }
|
|
16638
17592
|
});
|
|
16639
17593
|
}
|
|
16640
17594
|
return {
|
|
@@ -16802,7 +17756,10 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
16802
17756
|
creditReserves: deps.pool.creditReservesInOrder(),
|
|
16803
17757
|
ignored: deps.ignoredAccounts?.() ?? [],
|
|
16804
17758
|
catalogSource: deps.catalogs[region].currentSource(),
|
|
16805
|
-
...
|
|
17759
|
+
...(() => {
|
|
17760
|
+
const updatedAt = deps.catalogs[region].catalogUpdatedAt();
|
|
17761
|
+
return updatedAt === void 0 ? {} : { catalogUpdatedAt: updatedAt };
|
|
17762
|
+
})(),
|
|
16806
17763
|
...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) },
|
|
16807
17764
|
...deps.pool.skippedFilesInOrder().length === 0 ? {} : { skippedFiles: deps.pool.skippedFilesInOrder().map((entry) => ({
|
|
16808
17765
|
file: basename(entry.path),
|
|
@@ -17291,33 +18248,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17291
18248
|
}
|
|
17292
18249
|
});
|
|
17293
18250
|
/**
|
|
17294
|
-
* The billion-context relay.
|
|
17295
|
-
*
|
|
17296
|
-
* GET only, loopback origin only. Its own route because it is the one
|
|
17297
|
-
* document here that comes from ANOTHER plugin's loopback listener: the
|
|
17298
|
-
* proxy publishes its own JSON and this only reads it, so folding it into
|
|
17299
|
-
* the pool status document would put a third-party service on the critical
|
|
17300
|
-
* path of every 30-second pool poll.
|
|
17301
|
-
*
|
|
17302
|
-
* Answered even when the proxy is absent — `state: 'missing'` with the
|
|
17303
|
-
* install command is far more actionable than a 503, and it is the normal
|
|
17304
|
-
* state on a machine that has not installed billion-context.
|
|
17305
|
-
*/
|
|
17306
|
-
const disposeBillion = ctx.webServer.register({
|
|
17307
|
-
kind: "exact",
|
|
17308
|
-
path: POOL_BILLION_PATH,
|
|
17309
|
-
handler: async (req, res) => {
|
|
17310
|
-
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
17311
|
-
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
17312
|
-
if (deps.billionDocument === void 0) return json(res, 503, { error: "the billion-context relay is not available in this build" });
|
|
17313
|
-
try {
|
|
17314
|
-
json(res, 200, await deps.billionDocument());
|
|
17315
|
-
} catch (error) {
|
|
17316
|
-
json(res, 500, { error: safeMessage(error) });
|
|
17317
|
-
}
|
|
17318
|
-
}
|
|
17319
|
-
});
|
|
17320
|
-
/**
|
|
17321
18251
|
* The chain list plus the roster its members are drawn from.
|
|
17322
18252
|
*
|
|
17323
18253
|
* GET only. Deliberately not part of the pool status document: chains change
|
|
@@ -17328,12 +18258,12 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17328
18258
|
const disposeCombos = ctx.webServer.register({
|
|
17329
18259
|
kind: "exact",
|
|
17330
18260
|
path: POOL_COMBOS_PATH,
|
|
17331
|
-
handler: (req, res) => {
|
|
18261
|
+
handler: async (req, res) => {
|
|
17332
18262
|
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
17333
18263
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
17334
18264
|
if (deps.combosDocument === void 0) return json(res, 503, { error: "the routing chains are not available in this build" });
|
|
17335
18265
|
try {
|
|
17336
|
-
json(res, 200, deps.combosDocument());
|
|
18266
|
+
json(res, 200, await deps.combosDocument());
|
|
17337
18267
|
} catch (error) {
|
|
17338
18268
|
json(res, 500, { error: safeMessage(error) });
|
|
17339
18269
|
}
|
|
@@ -17354,8 +18284,10 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17354
18284
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
17355
18285
|
if (deps.saveCombos === void 0) return json(res, 503, { error: "the routing chains are not available in this build" });
|
|
17356
18286
|
try {
|
|
17357
|
-
const
|
|
17358
|
-
|
|
18287
|
+
const body = await readJsonBody(req);
|
|
18288
|
+
const raw = Array.isArray(body) ? body : body["combos"];
|
|
18289
|
+
if (!Array.isArray(raw)) return json(res, 400, { error: "expected {combos: [...]} (or a bare array from older clients); refusing to replace the chain table with an empty one" });
|
|
18290
|
+
json(res, 200, await deps.saveCombos(raw));
|
|
17359
18291
|
} catch (error) {
|
|
17360
18292
|
json(res, 500, { error: safeMessage(error) });
|
|
17361
18293
|
}
|
|
@@ -17393,7 +18325,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17393
18325
|
disposeRelay();
|
|
17394
18326
|
disposeCombosSave();
|
|
17395
18327
|
disposeCombos();
|
|
17396
|
-
disposeBillion();
|
|
17397
18328
|
disposeChannels();
|
|
17398
18329
|
disposeAggregate();
|
|
17399
18330
|
disposeAggregateDiscover();
|
|
@@ -17417,247 +18348,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17417
18348
|
}, "dsh-xdpool: Web status route");
|
|
17418
18349
|
}
|
|
17419
18350
|
//#endregion
|
|
17420
|
-
//#region src/billion.ts
|
|
17421
|
-
/**
|
|
17422
|
-
* Read-only observation of a locally running billion-context proxy.
|
|
17423
|
-
*
|
|
17424
|
-
* WHAT THIS IS, AND WHY IT IS NOT AN EMBEDDED COMPRESSOR
|
|
17425
|
-
*
|
|
17426
|
-
* billion-context is a separate DSH plugin that is very likely already
|
|
17427
|
-
* installed on this machine — on a stock desktop profile it is mounted by the
|
|
17428
|
-
* profile's bundle layer and runs its own loopback listener. This module never
|
|
17429
|
-
* implements compression and never proposes to: it finds that listener and
|
|
17430
|
-
* relays a few numbers so the card can show what the compressor is doing.
|
|
17431
|
-
* Re-implementing folding here would mean a second, divergent answer to the
|
|
17432
|
-
* same question and a second set of ways to get it wrong.
|
|
17433
|
-
*
|
|
17434
|
-
* HOW THE LISTENER IS FOUND
|
|
17435
|
-
*
|
|
17436
|
-
* The proxy picks an ephemeral port at spawn and records it in its own state
|
|
17437
|
-
* directory, which we read rather than guess. Guessing would mean scanning a
|
|
17438
|
-
* port range on every card poll, and a wrong guess is worse than no answer: it
|
|
17439
|
-
* could read some unrelated loopback service. The recorded file is
|
|
17440
|
-
* `<state>/billion-context/proxy-origin` and holds a JSON object with at least
|
|
17441
|
-
* an `origin`; `port-zone.json` is the same fact keyed by lane. Both are read,
|
|
17442
|
-
* and the origin file wins, because it is the one the proxy itself reads on
|
|
17443
|
-
* startup.
|
|
17444
|
-
*
|
|
17445
|
-
* `state` is resolved the same way the proxy resolves it: `XDG_STATE_HOME` when
|
|
17446
|
-
* set, else `<homedir>/.local/state` on every platform including Windows — not
|
|
17447
|
-
* `%LOCALAPPDATA%`, which is what a Windows developer would reach for first and
|
|
17448
|
-
* which would therefore be wrong.
|
|
17449
|
-
*
|
|
17450
|
-
* WHAT IS RELAYED
|
|
17451
|
-
*
|
|
17452
|
-
* Counts, sizes, timestamps. Not titles, not first-block hints, not any
|
|
17453
|
-
* conversation material. The proxy is happy to hand over more than that, and
|
|
17454
|
-
* taking it would put the user's own words into a document the card fetches
|
|
17455
|
-
* every 30 seconds and that lives in the plugin's HTTP log.
|
|
17456
|
-
*
|
|
17457
|
-
* The one addition to that list is the proxy's own origin, so the card can link
|
|
17458
|
-
* out to the dashboard the proxy already ships instead of redrawing it. That is
|
|
17459
|
-
* a service address rather than conversation material, and anyone who can read
|
|
17460
|
-
* this document can already reach that listener directly — which is why it is
|
|
17461
|
-
* safe to publish where a title would not be.
|
|
17462
|
-
*
|
|
17463
|
-
* The proxy also has NO AUTHENTICATION by its own documentation — the README
|
|
17464
|
-
* says so outright — so every request here is a hard local one, and the relay
|
|
17465
|
-
* only ever reads `/__bili/health` and `/__bili/sessions`. Both are GETs. This
|
|
17466
|
-
* module has no code path that could write to that proxy.
|
|
17467
|
-
*
|
|
17468
|
-
* @module dsh-xdpool/billion
|
|
17469
|
-
*/
|
|
17470
|
-
/** The proxy's own management prefix. Never guess it; it is theirs. */
|
|
17471
|
-
const BILI_PREFIX = "/__bili";
|
|
17472
|
-
/** Loopback only. The proxy binds 127.0.0.1 and we refuse anything else. */
|
|
17473
|
-
const LOOPBACK_HOSTS$1 = [
|
|
17474
|
-
"127.0.0.1",
|
|
17475
|
-
"localhost",
|
|
17476
|
-
"[::1]",
|
|
17477
|
-
"::1"
|
|
17478
|
-
];
|
|
17479
|
-
/**
|
|
17480
|
-
* How long one relay request may take.
|
|
17481
|
-
*
|
|
17482
|
-
* Short on purpose: this runs inside the card's status request, and a stalled
|
|
17483
|
-
* third-party proxy must degrade to a panel that says "no answer" rather than
|
|
17484
|
-
* hold the pool card hostage. The proxy answers these two endpoints in
|
|
17485
|
-
* milliseconds; the budget is for the case where it does not.
|
|
17486
|
-
*/
|
|
17487
|
-
const RELAY_TIMEOUT_MS = 1500;
|
|
17488
|
-
/** How many sessions the card is shown. 50 rows is already past the fold. */
|
|
17489
|
-
const MAX_SESSIONS = 50;
|
|
17490
|
-
/**
|
|
17491
|
-
* Where the proxy keeps its state directory.
|
|
17492
|
-
*
|
|
17493
|
-
* Mirrors the proxy's own resolution exactly: `XDG_STATE_HOME` when it holds an
|
|
17494
|
-
* absolute path, else `<homedir>/.local/state`. Deliberately NOT
|
|
17495
|
-
* `DSH_HOME ?? homedir()` — DSH_HOME is the profile root and a live value on a
|
|
17496
|
-
* real desktop install, so preferring it sent this reader to
|
|
17497
|
-
* `<profile>/.local/state/billion-context` and reported "missing" on a machine
|
|
17498
|
-
* where the proxy was running and answering. Probed against the live proxy,
|
|
17499
|
-
* which is the only way that mistake is visible: every unit test with a faked
|
|
17500
|
-
* state directory passes either way.
|
|
17501
|
-
*
|
|
17502
|
-
* Undefined when neither resolves, because inventing a path to probe would
|
|
17503
|
-
* turn "not installed" into a silent hunt through the filesystem.
|
|
17504
|
-
*/
|
|
17505
|
-
function stateRoot() {
|
|
17506
|
-
const xdg = env.XDG_STATE_HOME;
|
|
17507
|
-
if (typeof xdg === "string" && xdg !== "") {
|
|
17508
|
-
if (xdg.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(xdg)) return join(xdg, "billion-context");
|
|
17509
|
-
return;
|
|
17510
|
-
}
|
|
17511
|
-
const home = homedir();
|
|
17512
|
-
if (typeof home !== "string" || home === "") return void 0;
|
|
17513
|
-
return join(home, ".local", "state", "billion-context");
|
|
17514
|
-
}
|
|
17515
|
-
/** A loopback http(s) origin, or undefined for anything else. */
|
|
17516
|
-
function parseLoopbackOrigin(raw) {
|
|
17517
|
-
let url;
|
|
17518
|
-
try {
|
|
17519
|
-
url = new URL(raw);
|
|
17520
|
-
} catch {
|
|
17521
|
-
return;
|
|
17522
|
-
}
|
|
17523
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
17524
|
-
if (!LOOPBACK_HOSTS$1.includes(url.hostname.toLowerCase())) return void 0;
|
|
17525
|
-
return url.origin;
|
|
17526
|
-
}
|
|
17527
|
-
/**
|
|
17528
|
-
* The proxy origin, from the file the proxy itself reads on startup.
|
|
17529
|
-
*
|
|
17530
|
-
* `port-zone.json` is the fallback because it is keyed by lane and therefore
|
|
17531
|
-
* distinguishes the dsh proxy from, say, a concurrently running opencode one —
|
|
17532
|
-
* but `proxy-origin` is preferred because it is the single-writer record.
|
|
17533
|
-
*/
|
|
17534
|
-
async function readProxyOrigin() {
|
|
17535
|
-
const root = stateRoot();
|
|
17536
|
-
if (root === void 0) return void 0;
|
|
17537
|
-
for (const name of ["proxy-origin", "port-zone.json"]) {
|
|
17538
|
-
let raw;
|
|
17539
|
-
try {
|
|
17540
|
-
raw = await readFile(join(root, name), "utf8");
|
|
17541
|
-
} catch {
|
|
17542
|
-
continue;
|
|
17543
|
-
}
|
|
17544
|
-
if (name === "proxy-origin") {
|
|
17545
|
-
try {
|
|
17546
|
-
const parsed = JSON.parse(raw);
|
|
17547
|
-
const origin = typeof parsed.origin === "string" ? parseLoopbackOrigin(parsed.origin) : void 0;
|
|
17548
|
-
if (origin !== void 0) return origin;
|
|
17549
|
-
} catch {}
|
|
17550
|
-
continue;
|
|
17551
|
-
}
|
|
17552
|
-
try {
|
|
17553
|
-
const port = JSON.parse(raw).lanes?.dsh;
|
|
17554
|
-
if (typeof port === "number" && Number.isInteger(port) && port > 0 && port < 65536) return `http://127.0.0.1:${port}`;
|
|
17555
|
-
} catch {
|
|
17556
|
-
continue;
|
|
17557
|
-
}
|
|
17558
|
-
}
|
|
17559
|
-
}
|
|
17560
|
-
/** One GET against the proxy, with the relay budget on it. Never throws. */
|
|
17561
|
-
async function fetchJson(origin, path) {
|
|
17562
|
-
const controller = new AbortController();
|
|
17563
|
-
const timer = setTimeout(() => {
|
|
17564
|
-
controller.abort();
|
|
17565
|
-
}, RELAY_TIMEOUT_MS);
|
|
17566
|
-
try {
|
|
17567
|
-
const response = await fetch(new URL(BILI_PREFIX + path, origin), {
|
|
17568
|
-
method: "GET",
|
|
17569
|
-
headers: { accept: "application/json" },
|
|
17570
|
-
signal: controller.signal
|
|
17571
|
-
});
|
|
17572
|
-
if (!response.ok) return void 0;
|
|
17573
|
-
return await response.json();
|
|
17574
|
-
} catch {
|
|
17575
|
-
return;
|
|
17576
|
-
} finally {
|
|
17577
|
-
clearTimeout(timer);
|
|
17578
|
-
}
|
|
17579
|
-
}
|
|
17580
|
-
/** A finite number, or undefined. Guards every relayed field. */
|
|
17581
|
-
function num(value) {
|
|
17582
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
17583
|
-
}
|
|
17584
|
-
/** A string, or ''. */
|
|
17585
|
-
function str(value) {
|
|
17586
|
-
return typeof value === "string" ? value : "";
|
|
17587
|
-
}
|
|
17588
|
-
/** A boolean, or false. */
|
|
17589
|
-
function bool(value) {
|
|
17590
|
-
return value === true;
|
|
17591
|
-
}
|
|
17592
|
-
/**
|
|
17593
|
-
* One session row, reduced.
|
|
17594
|
-
*
|
|
17595
|
-
* Every field is defaulted rather than passed through: the proxy's document is
|
|
17596
|
-
* a moving target across versions, and an unreadable row should render as
|
|
17597
|
-
* zeroes we can explain, not as `undefined` reaching the card.
|
|
17598
|
-
*/
|
|
17599
|
-
function toSession(raw) {
|
|
17600
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
17601
|
-
const row = raw;
|
|
17602
|
-
const id = str(row.id);
|
|
17603
|
-
if (id === "") return void 0;
|
|
17604
|
-
return {
|
|
17605
|
-
id,
|
|
17606
|
-
protocol: str(row.protocol),
|
|
17607
|
-
live: bool(row.live),
|
|
17608
|
-
requests: num(row.requests) ?? 0,
|
|
17609
|
-
contextTokens: num(row.contextTokens) ?? 0,
|
|
17610
|
-
contextTokensSource: str(row.contextTokensSource),
|
|
17611
|
-
tokensSaved: num(row.tokensSaved) ?? 0,
|
|
17612
|
-
inputTokens: num(row.inputTokens) ?? 0,
|
|
17613
|
-
cachedTokens: num(row.cachedTokens) ?? 0,
|
|
17614
|
-
outputTokens: num(row.outputTokens) ?? 0,
|
|
17615
|
-
cacheHitPct: num(row.cacheHitPct) ?? -1,
|
|
17616
|
-
blocks: num(row.blocks) ?? 0,
|
|
17617
|
-
contextWindow: num(row.contextWindow) ?? 0,
|
|
17618
|
-
lastSeen: str(row.lastSeen),
|
|
17619
|
-
lastModel: str(row.lastModel)
|
|
17620
|
-
};
|
|
17621
|
-
}
|
|
17622
|
-
/** The whole relay, safe to hand the card: it never rejects. */
|
|
17623
|
-
async function readBillionStatus() {
|
|
17624
|
-
const origin = await readProxyOrigin();
|
|
17625
|
-
if (origin === void 0) return {
|
|
17626
|
-
state: "missing",
|
|
17627
|
-
reason: "billion-context is not running on this machine. Install it with \"dsh plugin --profile <name> add billion-context\" and this page fills in.",
|
|
17628
|
-
sessions: [],
|
|
17629
|
-
totalSessions: 0
|
|
17630
|
-
};
|
|
17631
|
-
const health = await fetchJson(origin, "/health");
|
|
17632
|
-
if (health === null || typeof health !== "object") return {
|
|
17633
|
-
state: "unreachable",
|
|
17634
|
-
reason: `billion-context is recorded at ${origin} but did not answer. It may have just been restarted.`,
|
|
17635
|
-
origin,
|
|
17636
|
-
sessions: [],
|
|
17637
|
-
totalSessions: 0
|
|
17638
|
-
};
|
|
17639
|
-
const meta = health;
|
|
17640
|
-
const sessionsDoc = await fetchJson(origin, "/sessions");
|
|
17641
|
-
const rawSessions = sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.sessions : void 0;
|
|
17642
|
-
const rows = Array.isArray(rawSessions) ? rawSessions.slice(0, MAX_SESSIONS) : [];
|
|
17643
|
-
const sessions = [];
|
|
17644
|
-
for (const row of rows) {
|
|
17645
|
-
const session = toSession(row);
|
|
17646
|
-
if (session !== void 0) sessions.push(session);
|
|
17647
|
-
}
|
|
17648
|
-
const total = num(sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.total : void 0) ?? sessions.length;
|
|
17649
|
-
const version = str(meta.version);
|
|
17650
|
-
const startedAt = num(meta.startedAt);
|
|
17651
|
-
return {
|
|
17652
|
-
state: "ready",
|
|
17653
|
-
origin,
|
|
17654
|
-
...version === "" ? {} : { version },
|
|
17655
|
-
...startedAt === void 0 ? {} : { startedAt },
|
|
17656
|
-
sessions,
|
|
17657
|
-
totalSessions: total
|
|
17658
|
-
};
|
|
17659
|
-
}
|
|
17660
|
-
//#endregion
|
|
17661
18351
|
//#region src/diagnostics.ts
|
|
17662
18352
|
/**
|
|
17663
18353
|
* A startup trace this plugin owns.
|
|
@@ -17722,6 +18412,46 @@ function recordStartupNote(line) {
|
|
|
17722
18412
|
} catch {}
|
|
17723
18413
|
return text;
|
|
17724
18414
|
}
|
|
18415
|
+
//#endregion
|
|
18416
|
+
//#region src/model-switch-bridge.ts
|
|
18417
|
+
/**
|
|
18418
|
+
* The lane id the host's picker uses for each region.
|
|
18419
|
+
*
|
|
18420
|
+
* The same two ids `POOL_OWNED_PROVIDERS` exempts inside the pack, and the same
|
|
18421
|
+
* two `REGION_CHANNEL_BY_REGION` maps in the card. Kept here as a literal
|
|
18422
|
+
* because this module is imported by the host half only, which must not pull in
|
|
18423
|
+
* the browser bundle's constants.
|
|
18424
|
+
*/
|
|
18425
|
+
const LANE_BY_REGION = {
|
|
18426
|
+
cn: "buddy",
|
|
18427
|
+
global: "workbuddy"
|
|
18428
|
+
};
|
|
18429
|
+
/**
|
|
18430
|
+
* Replace the selection and mirror it where the pack can see it.
|
|
18431
|
+
*
|
|
18432
|
+
* The mirror is `globalThis.__xdpoolLaneSelection`, which `pack.js` reads in its
|
|
18433
|
+
* `[xdpool-local] laneAllowedIds` helper. A global rather than a getter on the
|
|
18434
|
+
* account pool because the pack's adapters receive `accountPool` — this
|
|
18435
|
+
* plugin's own object — and nothing else that this module could hang a function
|
|
18436
|
+
* on without editing ten constructors.
|
|
18437
|
+
*/
|
|
18438
|
+
function applyLaneSelection(next) {
|
|
18439
|
+
globalThis.__xdpoolLaneSelection = next;
|
|
18440
|
+
}
|
|
18441
|
+
/**
|
|
18442
|
+
* Derive the lane keyed selection from the two settings documents.
|
|
18443
|
+
*
|
|
18444
|
+
* Split out from the caller so the mapping is testable without a host: the one
|
|
18445
|
+
* thing worth asserting is that an ABSENT setting becomes `undefined` (no
|
|
18446
|
+
* restriction) while an explicitly EMPTY `enabledModelIds` stays an empty array
|
|
18447
|
+
* (nothing allowed).
|
|
18448
|
+
*/
|
|
18449
|
+
function laneSelectionFrom(cn, global) {
|
|
18450
|
+
return {
|
|
18451
|
+
[LANE_BY_REGION.cn]: cn,
|
|
18452
|
+
[LANE_BY_REGION.global]: global
|
|
18453
|
+
};
|
|
18454
|
+
}
|
|
17725
18455
|
/** Hard ceiling on the whole probe, so a hung upstream cannot wedge the card. */
|
|
17726
18456
|
const PROBE_TIMEOUT_MS = 3e4;
|
|
17727
18457
|
/**
|
|
@@ -18044,9 +18774,7 @@ async function compactWithSummary(messages, options, deps, signal) {
|
|
|
18044
18774
|
const first = compactMessages(messages, options);
|
|
18045
18775
|
if (!first.changed) return {
|
|
18046
18776
|
messages: first.messages,
|
|
18047
|
-
tokens: first.tokens
|
|
18048
|
-
summary: void 0,
|
|
18049
|
-
skipped: void 0
|
|
18777
|
+
tokens: first.tokens
|
|
18050
18778
|
};
|
|
18051
18779
|
const summaryBudget = Math.max(256, Math.floor(options.budget / 4));
|
|
18052
18780
|
let toSummarize = first.dropped;
|
|
@@ -18075,8 +18803,8 @@ async function compactWithSummary(messages, options, deps, signal) {
|
|
|
18075
18803
|
}
|
|
18076
18804
|
if (summary === void 0) return {
|
|
18077
18805
|
messages: first.messages,
|
|
18078
|
-
|
|
18079
|
-
|
|
18806
|
+
tokens: first.tokens,
|
|
18807
|
+
...skipped === void 0 ? {} : { skipped }
|
|
18080
18808
|
};
|
|
18081
18809
|
const withSummary = injectSummary(first.messages, summary);
|
|
18082
18810
|
if (estimateMessagesTokens(withSummary) > options.budget) {
|
|
@@ -18496,6 +19224,19 @@ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
|
18496
19224
|
"localhost",
|
|
18497
19225
|
"[::1]"
|
|
18498
19226
|
]);
|
|
19227
|
+
/**
|
|
19228
|
+
* How long one chain member may hold the request before it is given up on.
|
|
19229
|
+
*
|
|
19230
|
+
* The caller's own signal covers "the user cancelled", not "this provider decided
|
|
19231
|
+
* to keep the socket open". Without a per-member deadline a single non-answering
|
|
19232
|
+
* upstream blocks the request forever, and the chain's whole purpose — try the
|
|
19233
|
+
* next member — never runs.
|
|
19234
|
+
*
|
|
19235
|
+
* 180s is the value the reference router settled on: long enough for a slow
|
|
19236
|
+
* reasoning model to emit its first token, short enough to give up inside one
|
|
19237
|
+
* turn rather than leaving the user staring at a spinner.
|
|
19238
|
+
*/
|
|
19239
|
+
const MEMBER_TIMEOUT_MS = 18e4;
|
|
18499
19240
|
/** HTTP status each upstream failure class surfaces as. */
|
|
18500
19241
|
const KIND_STATUS = {
|
|
18501
19242
|
hard_credit: 402,
|
|
@@ -18505,6 +19246,27 @@ const KIND_STATUS = {
|
|
|
18505
19246
|
server: 502,
|
|
18506
19247
|
client: 400
|
|
18507
19248
|
};
|
|
19249
|
+
/**
|
|
19250
|
+
* HTTP status for each "no account available" reason.
|
|
19251
|
+
*
|
|
19252
|
+
* The reason is already a complete diagnosis — `unavailableReason` separates
|
|
19253
|
+
* cooling from a drained reserve from a switched-off account from an empty pool
|
|
19254
|
+
* — and each of those needs a different user action. Flattening them into one
|
|
19255
|
+
* status throws that away: 401 universally reads as "invalid API key", so a
|
|
19256
|
+
* reserve or a disabled account sent the reader to re-authenticate over a
|
|
19257
|
+
* setting they had chosen themselves (#115).
|
|
19258
|
+
*
|
|
19259
|
+
* A reason this table does not know falls back to 503 at the call site rather
|
|
19260
|
+
* than 401: "this lane cannot serve you right now" is a true statement about an
|
|
19261
|
+
* unfamiliar state, while "bad key" is a specific and probably false one.
|
|
19262
|
+
*/
|
|
19263
|
+
const REASON_STATUS = {
|
|
19264
|
+
cooling: 429,
|
|
19265
|
+
session_dead: 401,
|
|
19266
|
+
reserve: 402,
|
|
19267
|
+
disabled: 403,
|
|
19268
|
+
empty: 503
|
|
19269
|
+
};
|
|
18508
19270
|
function hostnameOfHost(host) {
|
|
18509
19271
|
let hostname = host.trim().toLowerCase();
|
|
18510
19272
|
if (hostname.startsWith("[")) {
|
|
@@ -18663,6 +19425,34 @@ function createWorkBuddyShim(options) {
|
|
|
18663
19425
|
const server = createServer((req, res) => {
|
|
18664
19426
|
handle(req, res);
|
|
18665
19427
|
});
|
|
19428
|
+
async function acquireForRequest(modelId, conversationKey, signal) {
|
|
19429
|
+
const ordinary = await pool.acquire(modelId, region, conversationKey);
|
|
19430
|
+
const reserveExempt = /* @__PURE__ */ new Set();
|
|
19431
|
+
if (ordinary !== void 0 || modelId === void 0 || signal.aborted) return {
|
|
19432
|
+
account: ordinary,
|
|
19433
|
+
reserveExempt
|
|
19434
|
+
};
|
|
19435
|
+
const now = Date.now();
|
|
19436
|
+
const held = pool.list(region).filter((account) => pool.isReserved(account.id) && !pool.isDisabled(account.id) && (account.credentialDeadUntilMs ?? 0) <= now && account.cooldownUntilMs <= now && (account.modelCooldowns[modelId] ?? 0) <= now);
|
|
19437
|
+
const pricingSignal = AbortSignal.any([signal, AbortSignal.timeout(5e3)]);
|
|
19438
|
+
await Promise.all(held.map(async (account) => {
|
|
19439
|
+
try {
|
|
19440
|
+
const price = (await client.fetchModels(account.credential, pricingSignal)).find((model) => model.id === modelId)?.creditMultiplier;
|
|
19441
|
+
logger?.info?.(`dsh-xdpool: reserve pricing model=${modelId} region=${region ?? "any"} price=${price ?? "unknown"} exempt=${!pricingSignal.aborted && price === 0}`);
|
|
19442
|
+
if (!pricingSignal.aborted && price === 0) reserveExempt.add(account.id);
|
|
19443
|
+
} catch {
|
|
19444
|
+
logger?.info?.(`dsh-xdpool: reserve pricing unavailable model=${modelId} region=${region ?? "any"} aborted=${pricingSignal.aborted}`);
|
|
19445
|
+
}
|
|
19446
|
+
}));
|
|
19447
|
+
if (signal.aborted) return {
|
|
19448
|
+
account: void 0,
|
|
19449
|
+
reserveExempt
|
|
19450
|
+
};
|
|
19451
|
+
return {
|
|
19452
|
+
account: await pool.acquire(modelId, region, conversationKey, reserveExempt),
|
|
19453
|
+
reserveExempt
|
|
19454
|
+
};
|
|
19455
|
+
}
|
|
18666
19456
|
/**
|
|
18667
19457
|
* "The same model has a paid row" sentence for a 429 body, or undefined.
|
|
18668
19458
|
*
|
|
@@ -18954,10 +19744,22 @@ function createWorkBuddyShim(options) {
|
|
|
18954
19744
|
}
|
|
18955
19745
|
const body = withBareModelId(raw, parsed.modelId);
|
|
18956
19746
|
const frames = [];
|
|
19747
|
+
/**
|
|
19748
|
+
* One id per user request, so a retry across keys counts as ONE turn.
|
|
19749
|
+
*
|
|
19750
|
+
* The key-rotation loop inside the forwarder files each physical attempt, and
|
|
19751
|
+
* they must share this id or the panel would report "3 turns" for one question
|
|
19752
|
+
* that happened to trip a rate limit. Same spelling the pool lane uses.
|
|
19753
|
+
*/
|
|
19754
|
+
const aggregateTurnId = randomBytes(8).toString("hex");
|
|
18957
19755
|
try {
|
|
18958
19756
|
await forwardAggregateStreamed(resolved, parsed.modelId, body, {
|
|
18959
19757
|
signal,
|
|
18960
|
-
onData: (payload) => frames.push(payload)
|
|
19758
|
+
onData: (payload) => frames.push(payload),
|
|
19759
|
+
...usageRecorder === void 0 ? {} : { usage: {
|
|
19760
|
+
recorder: usageRecorder,
|
|
19761
|
+
turnId: aggregateTurnId
|
|
19762
|
+
} }
|
|
18961
19763
|
});
|
|
18962
19764
|
} catch (error) {
|
|
18963
19765
|
if (res.headersSent) {
|
|
@@ -19158,98 +19960,249 @@ function createWorkBuddyShim(options) {
|
|
|
19158
19960
|
}
|
|
19159
19961
|
const attempts = [];
|
|
19160
19962
|
let last;
|
|
19963
|
+
/**
|
|
19964
|
+
* Why the chain moved on from each member, in order.
|
|
19965
|
+
*
|
|
19966
|
+
* A chain hides its own machinery: the user sees one answer from an
|
|
19967
|
+
* unspecified member, and when the answer is worse/different/slower than
|
|
19968
|
+
* expected there is nothing on screen to explain it. The whole point of
|
|
19969
|
+
* reading a fallback trace is answering "why did it switch, and where did it
|
|
19970
|
+
* land" — so every skip records the member, the failure class and the wire
|
|
19971
|
+
* status, and the finished turn logs the sequence once.
|
|
19972
|
+
*
|
|
19973
|
+
* Logged, not returned: the caller asked for a completion, and a response
|
|
19974
|
+
* shape invented for diagnostics would break every client that parses it.
|
|
19975
|
+
*/
|
|
19976
|
+
const switches = [];
|
|
19977
|
+
const noteSwitch = (member, kind, status) => {
|
|
19978
|
+
switches.push(`${member} → ${kind} (${String(status)})`);
|
|
19979
|
+
logger?.warn?.(`dsh-xdpool: chain ${row.id} skipped ${member}: ${kind} (${String(status)}); trying next`);
|
|
19980
|
+
};
|
|
19161
19981
|
const conversationKey = conversationKeyOf(raw);
|
|
19162
19982
|
for (const member of members) {
|
|
19163
19983
|
if (signal.aborted) return;
|
|
19164
19984
|
attempts.push(member.modelId);
|
|
19165
|
-
|
|
19166
|
-
|
|
19167
|
-
|
|
19168
|
-
|
|
19985
|
+
const memberController = new AbortController();
|
|
19986
|
+
const memberSignal = memberController.signal;
|
|
19987
|
+
const relayAbort = () => {
|
|
19988
|
+
memberController.abort();
|
|
19989
|
+
};
|
|
19990
|
+
if (signal.aborted) relayAbort();
|
|
19991
|
+
else signal.addEventListener("abort", relayAbort, { once: true });
|
|
19992
|
+
const memberTimer = setTimeout(() => {
|
|
19993
|
+
memberController.abort();
|
|
19994
|
+
}, MEMBER_TIMEOUT_MS);
|
|
19995
|
+
memberTimer.unref?.();
|
|
19996
|
+
/** Release this member's timer and listener. Every exit path calls it. */
|
|
19997
|
+
const releaseMember = () => {
|
|
19998
|
+
clearTimeout(memberTimer);
|
|
19999
|
+
signal.removeEventListener("abort", relayAbort);
|
|
20000
|
+
};
|
|
20001
|
+
try {
|
|
20002
|
+
/**
|
|
20003
|
+
* A 扫码登录 channel member, served by the HOST's adapter registry.
|
|
20004
|
+
*
|
|
20005
|
+
* This is the branch that makes a chain able to name TRAE / Qoder /
|
|
20006
|
+
* CodeBuddy at all. It goes through `channels.turn`, the same seam the
|
|
20007
|
+
* single-model path uses, so the credential, endpoint and wire dialect are
|
|
20008
|
+
* the host's problem rather than ours — and the model a chain reaches is
|
|
20009
|
+
* literally the one the picker shows.
|
|
20010
|
+
*
|
|
20011
|
+
* Frames are BUFFERED like every other member (`frames.push`, written only on
|
|
20012
|
+
* success): "retry only before any visible content" is what the chain
|
|
20013
|
+
* promises, and streaming straight to the socket here would leave two models'
|
|
20014
|
+
* text spliced together on screen when a later member answered.
|
|
20015
|
+
*/
|
|
20016
|
+
if (member.host === true) {
|
|
20017
|
+
if (channels === void 0) {
|
|
20018
|
+
last = {
|
|
20019
|
+
kind: "combo_config",
|
|
20020
|
+
status: 503,
|
|
20021
|
+
message: `member ${member.modelId} needs the login-channel lane, which this build does not mount`
|
|
20022
|
+
};
|
|
20023
|
+
noteSwitch(member.modelId, "combo_config", 503);
|
|
20024
|
+
continue;
|
|
20025
|
+
}
|
|
20026
|
+
const channelTarget = channels.resolve(member.modelId);
|
|
20027
|
+
if (channelTarget === void 0) {
|
|
20028
|
+
last = {
|
|
20029
|
+
kind: "combo_config",
|
|
20030
|
+
status: 400,
|
|
20031
|
+
message: `member ${member.modelId} is not a resolvable channel model id`
|
|
20032
|
+
};
|
|
20033
|
+
noteSwitch(member.modelId, "combo_config", 400);
|
|
20034
|
+
continue;
|
|
20035
|
+
}
|
|
20036
|
+
const memberCreated = Math.floor(Date.now() / 1e3);
|
|
20037
|
+
const memberCompletionId = `chatcmpl-xdpool-${randomBytes(6).toString("base64url")}`;
|
|
20038
|
+
const hostFrames = [];
|
|
20039
|
+
const frameOf = (delta, reason) => JSON.stringify({
|
|
20040
|
+
id: memberCompletionId,
|
|
20041
|
+
object: "chat.completion.chunk",
|
|
20042
|
+
created: memberCreated,
|
|
20043
|
+
model: channelTarget.modelId,
|
|
20044
|
+
choices: [{
|
|
20045
|
+
index: 0,
|
|
20046
|
+
delta,
|
|
20047
|
+
finish_reason: reason
|
|
20048
|
+
}],
|
|
20049
|
+
usage: null
|
|
20050
|
+
});
|
|
20051
|
+
try {
|
|
20052
|
+
let finishReason = "stop";
|
|
20053
|
+
for await (const event of channels.turn({
|
|
20054
|
+
channel: channelTarget.channel,
|
|
20055
|
+
modelId: channelTarget.modelId,
|
|
20056
|
+
body: JSON.parse(withBareModelId(raw, channelTarget.modelId)),
|
|
20057
|
+
signal: memberSignal
|
|
20058
|
+
})) if (event.kind === "usage") {} else if (event.kind === "done") {
|
|
20059
|
+
if (typeof event.finishReason === "string" && event.finishReason !== "") finishReason = event.finishReason;
|
|
20060
|
+
} else {
|
|
20061
|
+
const delta = event.delta;
|
|
20062
|
+
if (delta["content"] !== void 0 || delta["reasoning_content"] !== void 0 || Array.isArray(delta["tool_calls"])) {}
|
|
20063
|
+
hostFrames.push(frameOf(delta, null));
|
|
20064
|
+
}
|
|
20065
|
+
/**
|
|
20066
|
+
* An empty answer is still an ANSWER.
|
|
20067
|
+
*
|
|
20068
|
+
* The reference router settled this and it is right: a model that replied
|
|
20069
|
+
* with nothing visible has responded, so falling through to the next
|
|
20070
|
+
* member would silently substitute a different model over a legitimate
|
|
20071
|
+
* (if unhelpful) result. The terminal frame is therefore always emitted,
|
|
20072
|
+
* whether or not anything preceded it.
|
|
20073
|
+
*/
|
|
20074
|
+
hostFrames.push(frameOf({}, finishReason));
|
|
20075
|
+
} catch (error) {
|
|
20076
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20077
|
+
const kind = error instanceof ChannelRequestError ? "client" : "server";
|
|
20078
|
+
last = {
|
|
20079
|
+
kind,
|
|
20080
|
+
status: kind === "client" ? 400 : 502,
|
|
20081
|
+
message: `channel ${channelTarget.channel}: ${message}`
|
|
20082
|
+
};
|
|
20083
|
+
noteSwitch(member.modelId, kind, last.status);
|
|
20084
|
+
continue;
|
|
20085
|
+
}
|
|
20086
|
+
res.writeHead(200, {
|
|
20087
|
+
"Content-Type": "text/event-stream",
|
|
20088
|
+
"Cache-Control": "no-cache",
|
|
20089
|
+
"Connection": "keep-alive",
|
|
20090
|
+
"X-Accel-Buffering": "no"
|
|
20091
|
+
});
|
|
20092
|
+
for (const payload of hostFrames) res.write(`data: ${payload}\n\n`);
|
|
20093
|
+
res.write("data: [DONE]\n\n");
|
|
20094
|
+
res.end();
|
|
20095
|
+
return;
|
|
20096
|
+
}
|
|
20097
|
+
if (!member.aggregate) {
|
|
20098
|
+
const { account, reserveExempt } = await acquireForRequest(member.modelId, conversationKey, memberSignal);
|
|
20099
|
+
if (account === void 0) {
|
|
20100
|
+
const why = pool.unavailableReason(member.modelId, region, reserveExempt);
|
|
20101
|
+
last = {
|
|
20102
|
+
kind: why.reason,
|
|
20103
|
+
status: REASON_STATUS[why.reason] ?? 503,
|
|
20104
|
+
message: `member ${member.modelId} has no usable WorkBuddy account (${why.reason})`
|
|
20105
|
+
};
|
|
20106
|
+
noteSwitch(member.modelId, why.reason, last.status);
|
|
20107
|
+
continue;
|
|
20108
|
+
}
|
|
20109
|
+
const memberRaw = JSON.stringify({
|
|
20110
|
+
...JSON.parse(raw),
|
|
20111
|
+
model: member.modelId
|
|
20112
|
+
});
|
|
20113
|
+
const prepared = client.prepareChatBodyFor(memberRaw, region ?? "cn");
|
|
20114
|
+
const result = await client.chatStream(account.credential, prepared, memberSignal, conversationKey);
|
|
20115
|
+
if (result.ok) {
|
|
20116
|
+
const turnId = randomBytes(8).toString("hex");
|
|
20117
|
+
await serveSuccessfulStream(res, account, result, logger, refreshBalance, pool, usageRecorder === void 0 ? void 0 : {
|
|
20118
|
+
recorder: usageRecorder,
|
|
20119
|
+
region: region ?? "cn",
|
|
20120
|
+
model: member.modelId,
|
|
20121
|
+
turnId
|
|
20122
|
+
});
|
|
20123
|
+
return;
|
|
20124
|
+
}
|
|
20125
|
+
last = {
|
|
20126
|
+
kind: result.kind,
|
|
20127
|
+
status: result.status,
|
|
20128
|
+
message: result.message
|
|
20129
|
+
};
|
|
20130
|
+
if (result.kind === "soft_rate") pool.penalize(account.id, parseRateLimitReset(result.message), member.modelId);
|
|
20131
|
+
else if (result.kind === "session_dead") pool.penalizeCredentialDead(account.id);
|
|
20132
|
+
else if (result.kind === "hard_credit") pool.penalizeExhausted(account.id);
|
|
20133
|
+
noteSwitch(member.modelId, result.kind, result.status);
|
|
20134
|
+
continue;
|
|
20135
|
+
}
|
|
20136
|
+
if (store === void 0 || aggregate === void 0) {
|
|
19169
20137
|
last = {
|
|
19170
|
-
kind:
|
|
19171
|
-
status:
|
|
19172
|
-
message:
|
|
20138
|
+
kind: "combo_config",
|
|
20139
|
+
status: 503,
|
|
20140
|
+
message: "the aggregate lane is not available on this profile"
|
|
19173
20141
|
};
|
|
20142
|
+
noteSwitch(member.modelId, "combo_config", 503);
|
|
19174
20143
|
continue;
|
|
19175
20144
|
}
|
|
19176
|
-
const
|
|
19177
|
-
|
|
19178
|
-
|
|
19179
|
-
|
|
19180
|
-
|
|
19181
|
-
|
|
19182
|
-
|
|
19183
|
-
|
|
19184
|
-
|
|
20145
|
+
const parsed = parseProviderModelId(member.modelId);
|
|
20146
|
+
if (parsed === null) {
|
|
20147
|
+
last = {
|
|
20148
|
+
kind: "combo_config",
|
|
20149
|
+
status: 400,
|
|
20150
|
+
message: `member ${member.modelId} is not a valid provider model id`
|
|
20151
|
+
};
|
|
20152
|
+
noteSwitch(member.modelId, "combo_config", 400);
|
|
20153
|
+
continue;
|
|
20154
|
+
}
|
|
20155
|
+
let resolved;
|
|
20156
|
+
try {
|
|
20157
|
+
resolved = resolveAggregateProvider(store, parsed.providerId);
|
|
20158
|
+
} catch (error) {
|
|
20159
|
+
last = {
|
|
20160
|
+
kind: "combo_config",
|
|
20161
|
+
status: 400,
|
|
20162
|
+
message: error instanceof Error ? error.message : String(error)
|
|
20163
|
+
};
|
|
20164
|
+
noteSwitch(member.modelId, "combo_config", 400);
|
|
20165
|
+
continue;
|
|
20166
|
+
}
|
|
20167
|
+
const frames = [];
|
|
20168
|
+
try {
|
|
20169
|
+
await forwardAggregateStreamed(resolved, parsed.modelId, withBareModelId(raw, parsed.modelId), {
|
|
20170
|
+
signal: memberSignal,
|
|
20171
|
+
onData: (payload) => frames.push(payload),
|
|
20172
|
+
...usageRecorder === void 0 ? {} : { usage: {
|
|
20173
|
+
recorder: usageRecorder,
|
|
20174
|
+
turnId: conversationKey ?? randomBytes(8).toString("hex")
|
|
20175
|
+
} }
|
|
19185
20176
|
});
|
|
19186
|
-
|
|
20177
|
+
} catch (error) {
|
|
20178
|
+
const kind = error instanceof AggregateError ? error.kind : "server";
|
|
20179
|
+
last = {
|
|
20180
|
+
kind,
|
|
20181
|
+
status: error instanceof AggregateError ? KIND_STATUS[error.kind] : 502,
|
|
20182
|
+
message: error instanceof Error ? error.message : String(error)
|
|
20183
|
+
};
|
|
20184
|
+
noteSwitch(member.modelId, kind, last.status);
|
|
20185
|
+
continue;
|
|
19187
20186
|
}
|
|
19188
|
-
|
|
19189
|
-
|
|
19190
|
-
|
|
19191
|
-
|
|
19192
|
-
|
|
19193
|
-
|
|
19194
|
-
else if (result.kind === "session_dead") pool.penalizeCredentialDead(account.id);
|
|
19195
|
-
else if (result.kind === "hard_credit") pool.penalizeExhausted(account.id);
|
|
19196
|
-
continue;
|
|
19197
|
-
}
|
|
19198
|
-
if (store === void 0 || aggregate === void 0) {
|
|
19199
|
-
last = {
|
|
19200
|
-
kind: "combo_config",
|
|
19201
|
-
status: 503,
|
|
19202
|
-
message: "the aggregate lane is not available on this profile"
|
|
19203
|
-
};
|
|
19204
|
-
continue;
|
|
19205
|
-
}
|
|
19206
|
-
const parsed = parseProviderModelId(member.modelId);
|
|
19207
|
-
if (parsed === null) {
|
|
19208
|
-
last = {
|
|
19209
|
-
kind: "combo_config",
|
|
19210
|
-
status: 400,
|
|
19211
|
-
message: `member ${member.modelId} is not a valid provider model id`
|
|
19212
|
-
};
|
|
19213
|
-
continue;
|
|
19214
|
-
}
|
|
19215
|
-
let resolved;
|
|
19216
|
-
try {
|
|
19217
|
-
resolved = resolveAggregateProvider(store, parsed.providerId);
|
|
19218
|
-
} catch (error) {
|
|
19219
|
-
last = {
|
|
19220
|
-
kind: "combo_config",
|
|
19221
|
-
status: 400,
|
|
19222
|
-
message: error instanceof Error ? error.message : String(error)
|
|
19223
|
-
};
|
|
19224
|
-
continue;
|
|
19225
|
-
}
|
|
19226
|
-
const frames = [];
|
|
19227
|
-
try {
|
|
19228
|
-
await forwardAggregateStreamed(resolved, parsed.modelId, withBareModelId(raw, parsed.modelId), {
|
|
19229
|
-
signal,
|
|
19230
|
-
onData: (payload) => frames.push(payload)
|
|
20187
|
+
if (switches.length > 0) logger?.info?.(`dsh-xdpool: chain ${row.id} served by ${member.modelId} after skipping ${String(switches.length)}/${String(members.length)}: ${switches.join("; ")}`);
|
|
20188
|
+
res.writeHead(200, {
|
|
20189
|
+
"Content-Type": "text/event-stream",
|
|
20190
|
+
"Cache-Control": "no-cache",
|
|
20191
|
+
"Connection": "keep-alive",
|
|
20192
|
+
"X-Accel-Buffering": "no"
|
|
19231
20193
|
});
|
|
19232
|
-
|
|
19233
|
-
|
|
19234
|
-
|
|
19235
|
-
|
|
19236
|
-
|
|
19237
|
-
|
|
19238
|
-
continue;
|
|
20194
|
+
for (const payload of frames) res.write(`data: ${payload}\n\n`);
|
|
20195
|
+
res.write("data: [DONE]\n\n");
|
|
20196
|
+
res.end();
|
|
20197
|
+
return;
|
|
20198
|
+
} finally {
|
|
20199
|
+
releaseMember();
|
|
19239
20200
|
}
|
|
19240
|
-
res.writeHead(200, {
|
|
19241
|
-
"Content-Type": "text/event-stream",
|
|
19242
|
-
"Cache-Control": "no-cache",
|
|
19243
|
-
"Connection": "keep-alive",
|
|
19244
|
-
"X-Accel-Buffering": "no"
|
|
19245
|
-
});
|
|
19246
|
-
for (const payload of frames) res.write(`data: ${payload}\n\n`);
|
|
19247
|
-
res.write("data: [DONE]\n\n");
|
|
19248
|
-
res.end();
|
|
19249
|
-
return;
|
|
19250
20201
|
}
|
|
19251
20202
|
const kind = last?.kind ?? "combo_empty";
|
|
19252
|
-
|
|
20203
|
+
const status = last?.status ?? 503;
|
|
20204
|
+
const trace = switches.length === 0 ? "" : ` [${switches.join("; ")}]`;
|
|
20205
|
+
writeOpenAIError(res, status, `combo_${kind}`, `chain ${row.id}: no member answered after ${attempts.length} [${attempts.join(" → ")}]: ${(last?.message ?? "no member was reachable").slice(0, 300)}${trace}`);
|
|
19253
20206
|
}
|
|
19254
20207
|
/**
|
|
19255
20208
|
* Serve one chat completion, rotating accounts on rate limits.
|
|
@@ -19338,9 +20291,9 @@ function createWorkBuddyShim(options) {
|
|
|
19338
20291
|
};
|
|
19339
20292
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
19340
20293
|
if (controller.signal.aborted) return;
|
|
19341
|
-
const account = await
|
|
20294
|
+
const { account, reserveExempt } = await acquireForRequest(modelId, conversationKey, controller.signal);
|
|
19342
20295
|
if (account === void 0) {
|
|
19343
|
-
const why = pool.unavailableReason(modelId, region);
|
|
20296
|
+
const why = pool.unavailableReason(modelId, region, reserveExempt);
|
|
19344
20297
|
if (why.reason === "cooling") {
|
|
19345
20298
|
const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
|
|
19346
20299
|
const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
|
|
@@ -19353,7 +20306,7 @@ function createWorkBuddyShim(options) {
|
|
|
19353
20306
|
return;
|
|
19354
20307
|
}
|
|
19355
20308
|
if (why.reason === "reserve") {
|
|
19356
|
-
writeOpenAIError(res, 402, "reserve_floor", `
|
|
20309
|
+
writeOpenAIError(res, 402, "reserve_floor", `no WorkBuddy account is available; every account not excluded by a cooldown or a revoked sign-in is at or below its credit reserve (${why.total} account(s) total); lower the reserve on the pool card or top up credits`);
|
|
19357
20310
|
return;
|
|
19358
20311
|
}
|
|
19359
20312
|
if (why.reason === "session_dead") {
|
|
@@ -19370,6 +20323,7 @@ function createWorkBuddyShim(options) {
|
|
|
19370
20323
|
return;
|
|
19371
20324
|
}
|
|
19372
20325
|
usageRecorder?.record({
|
|
20326
|
+
channel: "workbuddy",
|
|
19373
20327
|
region: region ?? "cn",
|
|
19374
20328
|
model: modelId ?? "(none)",
|
|
19375
20329
|
account: account.label,
|
|
@@ -19413,7 +20367,7 @@ function createWorkBuddyShim(options) {
|
|
|
19413
20367
|
}
|
|
19414
20368
|
if (isContextTooLong(last.message)) {
|
|
19415
20369
|
const recovered = await recoverFromContextOverrun({
|
|
19416
|
-
raw,
|
|
20370
|
+
raw: poolRaw,
|
|
19417
20371
|
modelId,
|
|
19418
20372
|
controller,
|
|
19419
20373
|
region,
|
|
@@ -19421,8 +20375,12 @@ function createWorkBuddyShim(options) {
|
|
|
19421
20375
|
client,
|
|
19422
20376
|
pool,
|
|
19423
20377
|
maxAttempts,
|
|
19424
|
-
|
|
19425
|
-
|
|
20378
|
+
acquireAccount: async () => (await acquireForRequest(modelId, conversationKey, controller.signal)).account,
|
|
20379
|
+
...usageContext === void 0 ? {} : { usage: usageContext },
|
|
20380
|
+
...(() => {
|
|
20381
|
+
const window = modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow;
|
|
20382
|
+
return window === void 0 ? {} : { contextWindow: window };
|
|
20383
|
+
})()
|
|
19426
20384
|
});
|
|
19427
20385
|
if (recovered.ok) {
|
|
19428
20386
|
await serveSuccessfulStream(res, recovered.account, recovered.result, logger, refreshBalance, pool, usageContext);
|
|
@@ -19529,6 +20487,7 @@ async function serveSuccessfulStream(res, account, result, logger, refreshBalanc
|
|
|
19529
20487
|
recorded = true;
|
|
19530
20488
|
if (usage?.recorder === void 0) return;
|
|
19531
20489
|
usage.recorder.record({
|
|
20490
|
+
channel: "workbuddy",
|
|
19532
20491
|
region: usage.region,
|
|
19533
20492
|
model: usage.model,
|
|
19534
20493
|
account: account.label,
|
|
@@ -19618,7 +20577,7 @@ async function recoverFromContextOverrun(options) {
|
|
|
19618
20577
|
let compacted = messages;
|
|
19619
20578
|
let compactionDetail = "";
|
|
19620
20579
|
try {
|
|
19621
|
-
const summariser = await pool.acquire(modelId, region);
|
|
20580
|
+
const summariser = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
|
|
19622
20581
|
if (summariser === void 0) compactionDetail = "no account available to summarise with";
|
|
19623
20582
|
else {
|
|
19624
20583
|
const outcome = await compactWithSummary(messages, {
|
|
@@ -19652,7 +20611,7 @@ async function recoverFromContextOverrun(options) {
|
|
|
19652
20611
|
ok: false,
|
|
19653
20612
|
detail: "client disconnected"
|
|
19654
20613
|
};
|
|
19655
|
-
const account = await pool.acquire(modelId, region);
|
|
20614
|
+
const account = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
|
|
19656
20615
|
if (account === void 0) return {
|
|
19657
20616
|
ok: false,
|
|
19658
20617
|
detail: "no account available after compaction"
|
|
@@ -19660,6 +20619,7 @@ async function recoverFromContextOverrun(options) {
|
|
|
19660
20619
|
tried.push(account.label);
|
|
19661
20620
|
const result = await client.chatStream(account.credential, retryBody, controller.signal, conversationKeyOf(raw));
|
|
19662
20621
|
if (!result.ok) usage?.recorder.record({
|
|
20622
|
+
channel: "workbuddy",
|
|
19663
20623
|
region: usage.region,
|
|
19664
20624
|
model: usage.model,
|
|
19665
20625
|
account: account.label,
|
|
@@ -20454,7 +21414,7 @@ function readPreferredIdSync(path = preferredIdPath()) {
|
|
|
20454
21414
|
* free of documents that describe the absence of a decision.
|
|
20455
21415
|
*/
|
|
20456
21416
|
async function writePreferredId(accountId, path = preferredIdPath()) {
|
|
20457
|
-
await
|
|
21417
|
+
await ensureSecretDir(dirname(path));
|
|
20458
21418
|
if (accountId === void 0) {
|
|
20459
21419
|
await rm(path, { force: true });
|
|
20460
21420
|
return;
|
|
@@ -20464,7 +21424,7 @@ async function writePreferredId(accountId, path = preferredIdPath()) {
|
|
|
20464
21424
|
accountId: accountId.trim()
|
|
20465
21425
|
}, null, 2);
|
|
20466
21426
|
const temp = `${path}.tmp`;
|
|
20467
|
-
await
|
|
21427
|
+
await writeSecretFile(temp, `${payload}\n`);
|
|
20468
21428
|
await rename(temp, path);
|
|
20469
21429
|
}
|
|
20470
21430
|
//#endregion
|
|
@@ -20523,40 +21483,59 @@ function relaySettingsOf(store) {
|
|
|
20523
21483
|
* itself looks like it obeyed, and the user is the only one who can tell that a
|
|
20524
21484
|
* member they paid for is now a fallback to something free.
|
|
20525
21485
|
*/
|
|
20526
|
-
function combosStatusOf(store, catalog, poolRows) {
|
|
21486
|
+
function combosStatusOf(store, catalog, poolRows, hostRows = []) {
|
|
20527
21487
|
const chains = combosOf(store);
|
|
20528
21488
|
const providerRows = catalog.providerRows();
|
|
20529
21489
|
const settings = new Map(configuredProviderIds(store).map((providerId) => [providerId, providerSettingOf(store, providerId)]));
|
|
20530
|
-
const candidates = [
|
|
20531
|
-
|
|
20532
|
-
name: row.name,
|
|
20533
|
-
owner: "workbuddy",
|
|
20534
|
-
usable: true,
|
|
20535
|
-
contextWindow: row.contextWindow,
|
|
20536
|
-
supportsImages: row.supportsImages
|
|
20537
|
-
})), ...providerRows.map((row) => {
|
|
20538
|
-
const setting = settings.get(row.providerId);
|
|
20539
|
-
const provider = getProvider(row.providerId);
|
|
20540
|
-
return {
|
|
21490
|
+
const candidates = [
|
|
21491
|
+
...poolRows.map((row) => ({
|
|
20541
21492
|
id: row.id,
|
|
20542
21493
|
name: row.name,
|
|
20543
|
-
owner:
|
|
20544
|
-
usable:
|
|
21494
|
+
owner: "workbuddy",
|
|
21495
|
+
usable: true,
|
|
20545
21496
|
contextWindow: row.contextWindow,
|
|
20546
|
-
supportsImages: row.supportsImages
|
|
20547
|
-
|
|
20548
|
-
|
|
21497
|
+
supportsImages: row.supportsImages,
|
|
21498
|
+
siblings: []
|
|
21499
|
+
})),
|
|
21500
|
+
...providerRows.map((row) => {
|
|
21501
|
+
const setting = settings.get(row.providerId);
|
|
21502
|
+
const provider = getProvider(row.providerId);
|
|
21503
|
+
return {
|
|
21504
|
+
id: row.id,
|
|
21505
|
+
name: row.name,
|
|
21506
|
+
owner: row.providerId,
|
|
21507
|
+
usable: provider === null || setting === void 0 ? false : providerAuthUsable(provider, setting),
|
|
21508
|
+
contextWindow: row.contextWindow,
|
|
21509
|
+
supportsImages: row.supportsImages,
|
|
21510
|
+
siblings: siblingRoutes(row.id, {
|
|
21511
|
+
pool: poolRows,
|
|
21512
|
+
aggregate: providerRows
|
|
21513
|
+
}, row.providerId).filter((candidate) => candidate !== row.id)
|
|
21514
|
+
};
|
|
21515
|
+
}),
|
|
21516
|
+
...hostRows.map((row) => ({
|
|
21517
|
+
id: row.id,
|
|
21518
|
+
name: row.name,
|
|
21519
|
+
owner: row.channel,
|
|
21520
|
+
usable: true,
|
|
21521
|
+
contextWindow: COMBO_DEFAULT_CONTEXT_WINDOW,
|
|
21522
|
+
supportsImages: false,
|
|
21523
|
+
siblings: []
|
|
21524
|
+
}))
|
|
21525
|
+
];
|
|
20549
21526
|
const known = new Set(candidates.map((candidate) => candidate.id));
|
|
20550
21527
|
return {
|
|
20551
21528
|
combos: chains.map((chain) => {
|
|
20552
21529
|
const resolved = buildComboCatalog([chain], {
|
|
20553
21530
|
pool: poolRows,
|
|
20554
|
-
aggregate: providerRows
|
|
21531
|
+
aggregate: providerRows,
|
|
21532
|
+
host: hostRows
|
|
20555
21533
|
})[0];
|
|
20556
21534
|
return {
|
|
20557
21535
|
id: resolved?.id ?? comboId(chain.name),
|
|
20558
21536
|
name: chain.name,
|
|
20559
21537
|
models: resolved?.models ?? [],
|
|
21538
|
+
platforms: splitPlatforms(resolved?.models ?? []),
|
|
20560
21539
|
missing: chain.models.filter((modelId) => !known.has(modelId)),
|
|
20561
21540
|
strategy: resolved?.strategy ?? chain.strategy ?? "fallback",
|
|
20562
21541
|
stickyLimit: resolved?.stickyLimit ?? chain.stickyLimit ?? 1,
|
|
@@ -20745,7 +21724,18 @@ const Config = z.object({
|
|
|
20745
21724
|
modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
|
|
20746
21725
|
automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
|
|
20747
21726
|
automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
|
|
20748
|
-
sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
|
|
21727
|
+
sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)")),
|
|
21728
|
+
/**
|
|
21729
|
+
* The 免密 free lane (`free:` models).
|
|
21730
|
+
*
|
|
21731
|
+
* OFF unless set, and it is the only lane that works that way. It is a shared
|
|
21732
|
+
* third-party endpoint with no credential of its own, so there is nothing for
|
|
21733
|
+
* a user to "configure" — it simply appears in the picker or does not. On by
|
|
21734
|
+
* default it added a group nobody asked for that the aggregate switch does not
|
|
21735
|
+
* govern (it is not an aggregate provider), leaving no way to remove it
|
|
21736
|
+
* without editing a file. Reported as "关不掉,应该默认是关闭的".
|
|
21737
|
+
*/
|
|
21738
|
+
freeLane: asVolatile(z.boolean().default(false).description("Offer the shared 免密 free models (the \"XD Pool · 免费模型\" group). Off by default: it is a third-party endpoint with no key of your own."))
|
|
20749
21739
|
});
|
|
20750
21740
|
/** Live API, published for the CLI. */
|
|
20751
21741
|
let api;
|
|
@@ -20802,6 +21792,16 @@ function warnOnPiAiGenerationMismatch(ctx) {
|
|
|
20802
21792
|
} catch {}
|
|
20803
21793
|
}
|
|
20804
21794
|
/**
|
|
21795
|
+
* Late-bound switch for the 免密 lane, set once the catalog exists.
|
|
21796
|
+
*
|
|
21797
|
+
* `applyConfigFromSource` is defined before the catalog is built and can be
|
|
21798
|
+
* called at any point after, so it cannot close over the object directly without
|
|
21799
|
+
* a declaration-order dance. A one-line indirection states the ordering instead
|
|
21800
|
+
* of leaving it to be inferred: before the catalog exists this is a no-op, and
|
|
21801
|
+
* the catalog applies the current value itself the moment it is built.
|
|
21802
|
+
*/
|
|
21803
|
+
let freeLaneSwitch = () => {};
|
|
21804
|
+
/**
|
|
20805
21805
|
* Start the loopback endpoint, register the `xdpool` provider, and
|
|
20806
21806
|
* discover accounts. The provider registers only after `shim.ready` resolves,
|
|
20807
21807
|
* because its models read the shim origin at construction time.
|
|
@@ -20904,7 +21904,8 @@ function apply(ctx, config = {}) {
|
|
|
20904
21904
|
}
|
|
20905
21905
|
};
|
|
20906
21906
|
const applyConfigFromSource = () => {
|
|
20907
|
-
const { authFile, cooldownMs, distribution, disabledAccountIds, creditReserves, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation } = current();
|
|
21907
|
+
const { authFile, cooldownMs, distribution, disabledAccountIds, creditReserves, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation, freeLane: freeLaneEnabled } = current();
|
|
21908
|
+
freeLaneSwitch(freeLaneEnabled === true);
|
|
20908
21909
|
core.pool.applyConfig({
|
|
20909
21910
|
...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
|
|
20910
21911
|
...cooldownMs === void 0 ? {} : { cooldownMs },
|
|
@@ -20923,6 +21924,7 @@ function apply(ctx, config = {}) {
|
|
|
20923
21924
|
};
|
|
20924
21925
|
core.catalogs.cn.applySelection(modelSelectionCn ?? fallbackSelection("cn"));
|
|
20925
21926
|
core.catalogs.global.applySelection(modelSelectionGlobal ?? fallbackSelection("global"));
|
|
21927
|
+
applyLaneSelection(laneSelectionFrom(core.catalogs.cn.currentSelection().enabledModelIds, core.catalogs.global.currentSelection().enabledModelIds));
|
|
20926
21928
|
core.scheduler.applyConfig(automationOptions(automation));
|
|
20927
21929
|
};
|
|
20928
21930
|
const settingsService = ctx.settings;
|
|
@@ -21008,6 +22010,10 @@ function apply(ctx, config = {}) {
|
|
|
21008
22010
|
const aggregateCatalog = new AggregateCatalog(() => readAggregateSettingsSync(), () => core.catalogs.cn.current());
|
|
21009
22011
|
aggregateCatalog.refresh();
|
|
21010
22012
|
const freeCatalog = new FreeCatalog();
|
|
22013
|
+
freeLaneSwitch = (enabled) => {
|
|
22014
|
+
freeCatalog.setEnabled(enabled);
|
|
22015
|
+
};
|
|
22016
|
+
freeLaneSwitch(current().freeLane === true);
|
|
21011
22017
|
const freeLane = createFreeLaneSource(freeCatalog, ctx.logger, usageRecorder);
|
|
21012
22018
|
/**
|
|
21013
22019
|
* The login channels, for the LAN gateway.
|
|
@@ -21038,6 +22044,33 @@ function apply(ctx, config = {}) {
|
|
|
21038
22044
|
* object has no reason to carry a list it never consults.
|
|
21039
22045
|
*/
|
|
21040
22046
|
const lanModelsOf = () => readAggregateSettingsSync().relay?.models;
|
|
22047
|
+
/**
|
|
22048
|
+
* The login channels' models, as the route editor needs them.
|
|
22049
|
+
*
|
|
22050
|
+
* `channelLane.rows()` already caches per channel (60s TTL) and de-duplicates
|
|
22051
|
+
* concurrent requests, so calling it per routing-document request is cheap — but
|
|
22052
|
+
* it is ASYNC, and the chain table is built synchronously. Hence this helper: the
|
|
22053
|
+
* callers that can await do, and a failed listing degrades to the previous answer
|
|
22054
|
+
* rather than emptying the platform list (a channel that cannot be listed right
|
|
22055
|
+
* now must not make its models disappear from every chain).
|
|
22056
|
+
*
|
|
22057
|
+
* The two pool-owned channels are excluded by `channelLane` itself, so the same
|
|
22058
|
+
* model cannot appear once as a pool row and again as a channel row.
|
|
22059
|
+
*/
|
|
22060
|
+
let hostRosterCache = [];
|
|
22061
|
+
const hostRoster = async () => {
|
|
22062
|
+
try {
|
|
22063
|
+
hostRosterCache = (await channelLane.rows()).map((row) => ({
|
|
22064
|
+
id: row.id,
|
|
22065
|
+
channel: row.channel,
|
|
22066
|
+
modelId: row.modelId,
|
|
22067
|
+
name: row.name
|
|
22068
|
+
}));
|
|
22069
|
+
} catch (error) {
|
|
22070
|
+
ctx.logger?.warn?.("dsh-xdpool: could not list login-channel models:", error);
|
|
22071
|
+
}
|
|
22072
|
+
return hostRosterCache;
|
|
22073
|
+
};
|
|
21041
22074
|
const shims = {
|
|
21042
22075
|
cn: createWorkBuddyShim({
|
|
21043
22076
|
pool: core.pool,
|
|
@@ -21134,9 +22167,7 @@ function apply(ctx, config = {}) {
|
|
|
21134
22167
|
await setSetting("disabledAccountIds", next, next);
|
|
21135
22168
|
},
|
|
21136
22169
|
setCreditReserve: async (accountId, reserve) => {
|
|
21137
|
-
const next =
|
|
21138
|
-
if (reserve > 0) next[accountId] = reserve;
|
|
21139
|
-
else delete next[accountId];
|
|
22170
|
+
const next = creditReservesWith(current().creditReserves, accountId, reserve);
|
|
21140
22171
|
await setSetting("creditReserves", next, next);
|
|
21141
22172
|
},
|
|
21142
22173
|
setAccountIgnored: async (accountId, ignored) => {
|
|
@@ -21167,6 +22198,9 @@ function apply(ctx, config = {}) {
|
|
|
21167
22198
|
else await updateProviderSetting(patch.providerId, {
|
|
21168
22199
|
...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
|
|
21169
22200
|
...patch.apiKey === void 0 ? {} : { apiKey: patch.apiKey },
|
|
22201
|
+
...patch.apiKeys === void 0 ? {} : { apiKeys: patch.apiKeys },
|
|
22202
|
+
...patch.appendApiKey === void 0 ? {} : { appendApiKey: patch.appendApiKey },
|
|
22203
|
+
...patch.distribution === void 0 ? {} : { distribution: patch.distribution },
|
|
21170
22204
|
...patch.baseUrl === void 0 ? {} : { baseUrl: patch.baseUrl },
|
|
21171
22205
|
...patch.models === void 0 ? {} : { models: patch.models }
|
|
21172
22206
|
});
|
|
@@ -21304,13 +22338,12 @@ function apply(ctx, config = {}) {
|
|
|
21304
22338
|
}))
|
|
21305
22339
|
};
|
|
21306
22340
|
},
|
|
21307
|
-
combosDocument: () => combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current()),
|
|
22341
|
+
combosDocument: async () => combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current(), await hostRoster()),
|
|
21308
22342
|
saveCombos: async (raw) => {
|
|
21309
22343
|
await saveCombos(raw);
|
|
21310
22344
|
await aggregateCatalog.refresh();
|
|
21311
|
-
return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current());
|
|
22345
|
+
return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current(), await hostRoster());
|
|
21312
22346
|
},
|
|
21313
|
-
billionDocument: readBillionStatus,
|
|
21314
22347
|
/**
|
|
21315
22348
|
* Relay state plus the gateway's model picker.
|
|
21316
22349
|
*
|
|
@@ -21388,12 +22421,6 @@ function apply(ctx, config = {}) {
|
|
|
21388
22421
|
adoptChannelPackAccounts(scoped, core.pool, ctx.logger);
|
|
21389
22422
|
});
|
|
21390
22423
|
});
|
|
21391
|
-
mountBillionContext(ctx, ctx.logger).then((outcome) => {
|
|
21392
|
-
recordStartupNote(`billion-context ${outcome.state}`);
|
|
21393
|
-
if (outcome.state === "unavailable") ctx.logger.warn(`[xdpool] billion-context not mounted — ${outcome.reason}`);
|
|
21394
|
-
}, (error) => {
|
|
21395
|
-
recordStartupNote(`billion-context mount failed: ${describeError(error)}`);
|
|
21396
|
-
});
|
|
21397
22424
|
Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
|
|
21398
22425
|
if (stopped) return;
|
|
21399
22426
|
recordStartupNote(`both shims ready cn=${shims.cn.baseUrl()} global=${shims.global.baseUrl()}; entering registration`);
|