dsh-connect-trae 3.8.1 → 3.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -0
- package/README.md +6 -2
- package/lib/client.js +1935 -129
- package/lib/index.d.ts +1243 -613
- package/lib/index.js +1891 -39
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1012,6 +1012,49 @@ const TRAE_ACCOUNT_STATUS_PATH = "/plugins/dsh-connect-trae/account-status";
|
|
|
1012
1012
|
*/
|
|
1013
1013
|
const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
|
|
1014
1014
|
/**
|
|
1015
|
+
* Plugin-owned account-pool endpoint: one route for the pool's batch actions.
|
|
1016
|
+
*
|
|
1017
|
+
* 参考:`dsh-connect-workbuddy` 的 `WORKBUDDY_POOL_PATH`。本路由与该实现同形
|
|
1018
|
+
* (同一 `?action=` 风格、POST、loopback-only、per-row `accountId`),这样同时
|
|
1019
|
+
* 装了两个插件的用户面对的是同一套交互。
|
|
1020
|
+
*
|
|
1021
|
+
* `?action=checkin` and `?action=test` run over the whole member set; `remove`
|
|
1022
|
+
* addresses ONE account (the body carries `accountId`) and is the only way a
|
|
1023
|
+
* measurement of an account the user no longer wants can leave the store — a
|
|
1024
|
+
* deleted member otherwise keeps its row in the file forever, and a re-added
|
|
1025
|
+
* member would silently inherit an old exclusion.
|
|
1026
|
+
*
|
|
1027
|
+
* POST + loopback-only because both batches SPEND something: check-in sends a
|
|
1028
|
+
* claim, and a test is a real chat call. A page that is not the Host's own
|
|
1029
|
+
* loopback must not be able to trigger either.
|
|
1030
|
+
*/
|
|
1031
|
+
const TRAE_POOL_PATH = "/plugins/dsh-connect-trae/pool";
|
|
1032
|
+
/** Query parameter selecting which pool batch action a request means. */
|
|
1033
|
+
const TRAE_POOL_ACTION_PARAM = "action";
|
|
1034
|
+
/** Query parameter naming ONE pool account a per-row request addresses. */
|
|
1035
|
+
const TRAE_POOL_ACCOUNT_PARAM = "accountId";
|
|
1036
|
+
/** Read the pool action off a request URL; unknown/absent means undefined. */
|
|
1037
|
+
function poolActionOf(url) {
|
|
1038
|
+
const at = url.indexOf("?");
|
|
1039
|
+
if (at === -1) return void 0;
|
|
1040
|
+
const value = new URLSearchParams(url.slice(at + 1)).get(TRAE_POOL_ACTION_PARAM);
|
|
1041
|
+
return value === "checkin" || value === "test" || value === "remove" ? value : void 0;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Read the ONE account a per-row pool request addresses, from the QUERY string.
|
|
1045
|
+
*
|
|
1046
|
+
* Unlike the transfer route, the pool's `accountId` rides the URL rather than the
|
|
1047
|
+
* body, so a row action and a batch action are distinguishable before any bytes
|
|
1048
|
+
* are read — which is what lets the route refuse a malformed request without ever
|
|
1049
|
+
* touching the store.
|
|
1050
|
+
*/
|
|
1051
|
+
function poolAccountIdOf(url) {
|
|
1052
|
+
const at = url.indexOf("?");
|
|
1053
|
+
if (at === -1) return void 0;
|
|
1054
|
+
const value = new URLSearchParams(url.slice(at + 1)).get(TRAE_POOL_ACCOUNT_PARAM);
|
|
1055
|
+
return value === null || value === "" ? void 0 : value;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1015
1058
|
* Plugin-owned account transfer endpoint: batch export and import of a region's
|
|
1016
1059
|
* stored credentials (issue #37).
|
|
1017
1060
|
*
|
|
@@ -1739,6 +1782,14 @@ const CLI_DEFAULT_HOST = "https://api.trae.cn";
|
|
|
1739
1782
|
* working and upgrades itself the next time a credential is written; it is
|
|
1740
1783
|
* never written again.
|
|
1741
1784
|
*/
|
|
1785
|
+
/**
|
|
1786
|
+
* How long a FAILED per-account refresh is remembered.
|
|
1787
|
+
*
|
|
1788
|
+
* Long enough that a revoked refresh token costs one exchange per five minutes
|
|
1789
|
+
* instead of two per card poll, short enough that a transient network failure is
|
|
1790
|
+
* retried on the next screen the user opens.
|
|
1791
|
+
*/
|
|
1792
|
+
const REFRESH_RETRY_MS = 3e5;
|
|
1742
1793
|
const OWN_VERSION = 2;
|
|
1743
1794
|
/** The superseded single-credential layout, still read for migration. */
|
|
1744
1795
|
const OWN_VERSION_SINGLE = 1;
|
|
@@ -1819,6 +1870,22 @@ function traeAccountId(credential) {
|
|
|
1819
1870
|
const stable = `${credential.edition}\0${credential.userId || credential.accountName || "unknown"}`;
|
|
1820
1871
|
return createHash("sha256").update(stable).digest("hex").slice(0, 24);
|
|
1821
1872
|
}
|
|
1873
|
+
/**
|
|
1874
|
+
* The exact shape of an account id — sha256 hex sliced to 24 characters, which is
|
|
1875
|
+
* what {@link traeAccountId} emits and therefore the only string a request may
|
|
1876
|
+
* legitimately name.
|
|
1877
|
+
*
|
|
1878
|
+
* This regex is a traversal guard as much as a validator: an id becomes part of a
|
|
1879
|
+
* path (`$DSH_HOME/.trae-pool.<panel>.json` uses it as a key, and a credential
|
|
1880
|
+
* lookup receives it), so `../../etc/passwd` has to fail here rather than fail
|
|
1881
|
+
* somewhere closer to the filesystem. Exported so an HTTP route can answer a bad
|
|
1882
|
+
* id with 400 instead of letting an exception surface as a 500.
|
|
1883
|
+
*/
|
|
1884
|
+
const TRAE_ACCOUNT_ID_RE = /^[0-9a-f]{24}$/;
|
|
1885
|
+
/** Whether a request-supplied value is a well-formed Trae account id. */
|
|
1886
|
+
function isTraeAccountId(value) {
|
|
1887
|
+
return typeof value === "string" && TRAE_ACCOUNT_ID_RE.test(value);
|
|
1888
|
+
}
|
|
1822
1889
|
/** One stored credential, validated. Returns undefined for a malformed row. */
|
|
1823
1890
|
function credentialOfStored(stored) {
|
|
1824
1891
|
if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return void 0;
|
|
@@ -1897,6 +1964,37 @@ var TraeCredentialStore = class {
|
|
|
1897
1964
|
refresh;
|
|
1898
1965
|
refreshMarginMs;
|
|
1899
1966
|
inflight;
|
|
1967
|
+
/**
|
|
1968
|
+
* In-flight refreshes of individual accounts, one per account id.
|
|
1969
|
+
*
|
|
1970
|
+
* The card reads every member's balance in one poll and each balance can
|
|
1971
|
+
* decide to refresh, so without this dedupe the same account would exchange
|
|
1972
|
+
* its token several times in a second and the last writer would win over a
|
|
1973
|
+
* copy another read already holds.
|
|
1974
|
+
*/
|
|
1975
|
+
inflightByAccount = /* @__PURE__ */ new Map();
|
|
1976
|
+
/**
|
|
1977
|
+
* When a per-account refresh last FAILED, so a dead refresh token is not
|
|
1978
|
+
* exchanged again on every poll.
|
|
1979
|
+
*
|
|
1980
|
+
* The card reads every member's balance on each refresh, and a revoked refresh
|
|
1981
|
+
* token fails forever: without this memo each poll would spend two upstream
|
|
1982
|
+
* calls (the current contract and the superseded one) on an account no retry
|
|
1983
|
+
* can fix. Five minutes keeps a transient network failure cheap to retry while
|
|
1984
|
+
* a revoked sign-in costs almost nothing.
|
|
1985
|
+
*/
|
|
1986
|
+
refreshFailedAtMs = /* @__PURE__ */ new Map();
|
|
1987
|
+
/**
|
|
1988
|
+
* The account the NEXT request should bill, when a pool — not the user's
|
|
1989
|
+
* binding — is choosing (issue #44).
|
|
1990
|
+
*
|
|
1991
|
+
* Runtime only, never persisted: the binding in settings is what a save
|
|
1992
|
+
* writes and what the card's account table reports as selected, while this is
|
|
1993
|
+
* "who serves right now". Keeping them apart is what lets the pool rotate
|
|
1994
|
+
* without silently rewriting the user's choice, and what makes turning the
|
|
1995
|
+
* pool off restore the bound account with no rebind at all.
|
|
1996
|
+
*/
|
|
1997
|
+
rotatedAccountId;
|
|
1900
1998
|
constructor(options) {
|
|
1901
1999
|
this.storagePathOverride = options.storagePath;
|
|
1902
2000
|
this.edition = options.edition ?? "auto";
|
|
@@ -2106,9 +2204,50 @@ var TraeCredentialStore = class {
|
|
|
2106
2204
|
if (this.requireTenant && !isEnterpriseAccount(selected)) return this.preferred(offered);
|
|
2107
2205
|
return selected;
|
|
2108
2206
|
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Point this store at one specific account for the NEXT request, without
|
|
2209
|
+
* touching the user's saved binding (issue #44).
|
|
2210
|
+
*
|
|
2211
|
+
* Called by the account pool before each chat request. `undefined` clears the
|
|
2212
|
+
* override, which is how switching the pool off immediately restores the
|
|
2213
|
+
* binding — no rebind, no settings write, nothing to undo.
|
|
2214
|
+
*
|
|
2215
|
+
* A rotation is a REQUEST, not an assertion: `current()` honours it only when
|
|
2216
|
+
* the account still exists locally and this store's own eligibility rules
|
|
2217
|
+
* allow it, and otherwise falls back to the binding. So a rotation can never
|
|
2218
|
+
* invent a credential that {@link current} would refuse.
|
|
2219
|
+
*/
|
|
2220
|
+
setRotatedAccount(accountId) {
|
|
2221
|
+
if (this.rotatedAccountId === accountId) return;
|
|
2222
|
+
this.rotatedAccountId = accountId;
|
|
2223
|
+
this.inflight = void 0;
|
|
2224
|
+
}
|
|
2225
|
+
/** The rotation currently in effect, for diagnostics and the pool's own card. */
|
|
2226
|
+
rotatedAccount() {
|
|
2227
|
+
return this.rotatedAccountId;
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* The credential a rotation points at, or `undefined` when it cannot be
|
|
2231
|
+
* honoured (the login was removed from this machine, or a tenant-scoped store
|
|
2232
|
+
* refuses a personal account).
|
|
2233
|
+
*
|
|
2234
|
+
* Never substitutes a different account here: the caller then applies the
|
|
2235
|
+
* user's binding, which is the honest answer when the pool's choice is gone.
|
|
2236
|
+
*/
|
|
2237
|
+
rotated(discovered, offered) {
|
|
2238
|
+
const id = this.rotatedAccountId;
|
|
2239
|
+
if (id === void 0) return void 0;
|
|
2240
|
+
const credential = offered.find((item) => traeAccountId(item) === id) ?? discovered.find((item) => traeAccountId(item) === id);
|
|
2241
|
+
if (credential === void 0) return void 0;
|
|
2242
|
+
if (this.requireTenant && !isEnterpriseAccount(credential)) return void 0;
|
|
2243
|
+
return credential;
|
|
2244
|
+
}
|
|
2109
2245
|
async current() {
|
|
2110
2246
|
const discovered = await this.readAll();
|
|
2111
|
-
|
|
2247
|
+
const offered = this.offered(discovered);
|
|
2248
|
+
const rotated = this.rotated(discovered, offered);
|
|
2249
|
+
if (rotated !== void 0) return rotated;
|
|
2250
|
+
return this.inUse(discovered, offered);
|
|
2112
2251
|
}
|
|
2113
2252
|
/**
|
|
2114
2253
|
* The stored credential of one specific account, without changing which
|
|
@@ -2130,6 +2269,41 @@ var TraeCredentialStore = class {
|
|
|
2130
2269
|
return (await this.readAll()).find((credential) => traeAccountId(credential) === accountId);
|
|
2131
2270
|
}
|
|
2132
2271
|
/**
|
|
2272
|
+
* A SPECIFIC account's credential, refreshed when its access token is due.
|
|
2273
|
+
*
|
|
2274
|
+
* {@link credentialOf} is a pure lookup and hands back an expired token as-is,
|
|
2275
|
+
* which is right for the chat path (that request fails honestly) but wrong for
|
|
2276
|
+
* a READ of that account's own data: Trae's access token lives for days while
|
|
2277
|
+
* the refresh token lives for months, so a pooled account the user has not
|
|
2278
|
+
* used this week reads as "no balance" — the card showed a bare `—` for an
|
|
2279
|
+
* account whose refresh token was still good. Only the refresh margin decides
|
|
2280
|
+
* here, and a FAILED refresh still yields the stored credential so the caller
|
|
2281
|
+
* reports the failure it actually hit instead of inventing "not signed in".
|
|
2282
|
+
*
|
|
2283
|
+
* This never touches the binding or the rotation: refreshing account B must
|
|
2284
|
+
* not make B the account the next chat request bills.
|
|
2285
|
+
*/
|
|
2286
|
+
async resolveCredentialOf(accountId) {
|
|
2287
|
+
const credential = await this.credentialOf(accountId);
|
|
2288
|
+
if (credential === void 0) return void 0;
|
|
2289
|
+
if (credential.expiresAtMs > Date.now() + this.refreshMarginMs) return credential;
|
|
2290
|
+
const pending = this.inflightByAccount.get(accountId);
|
|
2291
|
+
if (pending !== void 0) return pending;
|
|
2292
|
+
const failedAt = this.refreshFailedAtMs.get(accountId);
|
|
2293
|
+
if (failedAt !== void 0 && Date.now() - failedAt < REFRESH_RETRY_MS) return credential;
|
|
2294
|
+
const attempt = this.refreshNow(credential).then((refreshed) => {
|
|
2295
|
+
this.refreshFailedAtMs.delete(accountId);
|
|
2296
|
+
return refreshed;
|
|
2297
|
+
}).catch(() => {
|
|
2298
|
+
this.refreshFailedAtMs.set(accountId, Date.now());
|
|
2299
|
+
return credential;
|
|
2300
|
+
}).finally(() => {
|
|
2301
|
+
this.inflightByAccount.delete(accountId);
|
|
2302
|
+
});
|
|
2303
|
+
this.inflightByAccount.set(accountId, attempt);
|
|
2304
|
+
return attempt;
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2133
2307
|
* Store imported credentials (issue #37), replacing any entry with the same
|
|
2134
2308
|
* account id and leaving every other entry alone.
|
|
2135
2309
|
*
|
|
@@ -2555,7 +2729,7 @@ async function refreshTraeCredential(credential, signal, device) {
|
|
|
2555
2729
|
}
|
|
2556
2730
|
//#endregion
|
|
2557
2731
|
//#region src/identity.ts
|
|
2558
|
-
function nonEmpty(value) {
|
|
2732
|
+
function nonEmpty$1(value) {
|
|
2559
2733
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
2560
2734
|
}
|
|
2561
2735
|
function deviceCenterId(storage) {
|
|
@@ -2580,8 +2754,8 @@ function deviceKeyPairOf(storage) {
|
|
|
2580
2754
|
const plaintext = parseTraeAuthValue(encoded);
|
|
2581
2755
|
if (typeof plaintext !== "object" || plaintext === null || Array.isArray(plaintext)) return void 0;
|
|
2582
2756
|
const value = plaintext;
|
|
2583
|
-
const privateKeyPEM = nonEmpty(value["privateKeyPEM"]);
|
|
2584
|
-
const publicKeyPEM = nonEmpty(value["publicKeyPEM"]);
|
|
2757
|
+
const privateKeyPEM = nonEmpty$1(value["privateKeyPEM"]);
|
|
2758
|
+
const publicKeyPEM = nonEmpty$1(value["publicKeyPEM"]);
|
|
2585
2759
|
return privateKeyPEM === void 0 || publicKeyPEM === void 0 ? void 0 : {
|
|
2586
2760
|
privateKeyPEM,
|
|
2587
2761
|
publicKeyPEM
|
|
@@ -2597,14 +2771,14 @@ async function readTraeIdentity(candidate, options = {}) {
|
|
|
2597
2771
|
const env = options.env ?? process.env;
|
|
2598
2772
|
const storage = JSON.parse(await readFile(candidate.path, "utf8"));
|
|
2599
2773
|
const appRoot = dirname(dirname(dirname(candidate.path)));
|
|
2600
|
-
const machineFile = nonEmpty(await readFile(join(appRoot, "machineid"), "utf8").catch(() => ""));
|
|
2601
|
-
const telemetryMachine = nonEmpty(storage["telemetry.machineId"]);
|
|
2602
|
-
const devDevice = nonEmpty(storage["telemetry.devDeviceId"]);
|
|
2774
|
+
const machineFile = nonEmpty$1(await readFile(join(appRoot, "machineid"), "utf8").catch(() => ""));
|
|
2775
|
+
const telemetryMachine = nonEmpty$1(storage["telemetry.machineId"]);
|
|
2776
|
+
const devDevice = nonEmpty$1(storage["telemetry.devDeviceId"]);
|
|
2603
2777
|
const dcDevice = deviceCenterId(storage);
|
|
2604
2778
|
const machineId = telemetryMachine ?? machineFile;
|
|
2605
2779
|
if (machineId === void 0) throw new Error(`Trae ${candidate.edition} has no stable machine identity`);
|
|
2606
2780
|
const deviceId = dcDevice ?? devDevice ?? createHash("sha256").update(machineId).digest("hex").slice(0, 32);
|
|
2607
|
-
const buildVersion = nonEmpty(storage["iCubeLastVersion"]);
|
|
2781
|
+
const buildVersion = nonEmpty$1(storage["iCubeLastVersion"]);
|
|
2608
2782
|
const appName = {
|
|
2609
2783
|
cn: "Trae CN",
|
|
2610
2784
|
sg: "Trae",
|
|
@@ -2624,18 +2798,18 @@ async function readTraeIdentity(candidate, options = {}) {
|
|
|
2624
2798
|
product = JSON.parse(await readFile(path, "utf8"));
|
|
2625
2799
|
break;
|
|
2626
2800
|
} catch {}
|
|
2627
|
-
const appVersion = nonEmpty(product["appVersion"]);
|
|
2628
|
-
const deviceBrand = platform === "darwin" ? nonEmpty(env["TRAE_DEVICE_BRAND"]) : void 0;
|
|
2801
|
+
const appVersion = nonEmpty$1(product["appVersion"]);
|
|
2802
|
+
const deviceBrand = platform === "darwin" ? nonEmpty$1(env["TRAE_DEVICE_BRAND"]) : void 0;
|
|
2629
2803
|
const deviceCpu = cpus()[0]?.model.split(" ")[0];
|
|
2630
2804
|
const osVersion = `${platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform} ${release()}`;
|
|
2631
2805
|
const deviceName = (() => {
|
|
2632
2806
|
try {
|
|
2633
|
-
return nonEmpty(userInfo().username);
|
|
2807
|
+
return nonEmpty$1(userInfo().username);
|
|
2634
2808
|
} catch {
|
|
2635
2809
|
return;
|
|
2636
2810
|
}
|
|
2637
2811
|
})();
|
|
2638
|
-
const deviceModel = nonEmpty(env["TRAE_DEVICE_MODEL"]);
|
|
2812
|
+
const deviceModel = nonEmpty$1(env["TRAE_DEVICE_MODEL"]);
|
|
2639
2813
|
const deviceKeyPair = deviceKeyPairOf(storage);
|
|
2640
2814
|
return {
|
|
2641
2815
|
edition: candidate.edition,
|
|
@@ -2712,12 +2886,12 @@ async function readTraeCliIdentity(edition, options = {}) {
|
|
|
2712
2886
|
const cliHome = join(home, CLI_HOME_BY_EDITION[edition]);
|
|
2713
2887
|
const argv = await readJsonFile(join(cliHome, "argv.json"));
|
|
2714
2888
|
const version = await readJsonFile(join(cliHome, "builtin", "ide_version.json"));
|
|
2715
|
-
const crashReporterId = nonEmpty(argv?.["crash-reporter-id"]);
|
|
2716
|
-
const host = nonEmpty(env["HOSTNAME"]) ?? await readHostname() ?? "unknown-host";
|
|
2717
|
-
const user = nonEmpty(env["USER"]) ?? nonEmpty(env["USERNAME"]) ?? "unknown-user";
|
|
2889
|
+
const crashReporterId = nonEmpty$1(argv?.["crash-reporter-id"]);
|
|
2890
|
+
const host = nonEmpty$1(env["HOSTNAME"]) ?? await readHostname() ?? "unknown-host";
|
|
2891
|
+
const user = nonEmpty$1(env["USER"]) ?? nonEmpty$1(env["USERNAME"]) ?? "unknown-user";
|
|
2718
2892
|
const deviceId = crashReporterId ?? createHash("sha256").update(`trae-cli\0${host}\0${user}`).digest("hex").slice(0, 32);
|
|
2719
2893
|
const machineId = createHash("sha256").update(`trae-cli-machine\0${deviceId}\0${host}`).digest("hex");
|
|
2720
|
-
const appVersion = nonEmpty(version?.["version"]);
|
|
2894
|
+
const appVersion = nonEmpty$1(version?.["version"]);
|
|
2721
2895
|
const deviceCpu = cpus()[0]?.model.split(" ")[0];
|
|
2722
2896
|
const osVersion = `${platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform} ${release()}`;
|
|
2723
2897
|
return {
|
|
@@ -2743,7 +2917,7 @@ async function readJsonFile(path) {
|
|
|
2743
2917
|
async function readHostname() {
|
|
2744
2918
|
try {
|
|
2745
2919
|
const { hostname } = await import("node:os");
|
|
2746
|
-
return nonEmpty(hostname());
|
|
2920
|
+
return nonEmpty$1(hostname());
|
|
2747
2921
|
} catch {
|
|
2748
2922
|
return;
|
|
2749
2923
|
}
|
|
@@ -2803,6 +2977,29 @@ const STATUS_BY_KIND = {
|
|
|
2803
2977
|
client: 400,
|
|
2804
2978
|
unconfigured: 503
|
|
2805
2979
|
};
|
|
2980
|
+
/**
|
|
2981
|
+
* Failures that say something about the ACCOUNT and are therefore worth another
|
|
2982
|
+
* account: a rejected token, a drained quota, a rate limit, a 5xx.
|
|
2983
|
+
*
|
|
2984
|
+
* `not_found`, `client` and `unconfigured` are deliberately absent — they say
|
|
2985
|
+
* something about the request or this build, and replaying them on a second
|
|
2986
|
+
* account would burn the same error twice while teaching the pool a false fact
|
|
2987
|
+
* about an account that never got a chance to serve.
|
|
2988
|
+
*/
|
|
2989
|
+
const FAILOVER_KINDS = /* @__PURE__ */ new Set([
|
|
2990
|
+
"authentication",
|
|
2991
|
+
"hard_credit",
|
|
2992
|
+
"soft_rate",
|
|
2993
|
+
"server"
|
|
2994
|
+
]);
|
|
2995
|
+
/**
|
|
2996
|
+
* Accounts one request may try.
|
|
2997
|
+
*
|
|
2998
|
+
* Not "all of them": every attempt costs a round-trip before the user sees a
|
|
2999
|
+
* byte, and a pool whose every member is down must fail with a reason instead of
|
|
3000
|
+
* turning one request into a slow tour of the roster.
|
|
3001
|
+
*/
|
|
3002
|
+
const MAX_POOL_ATTEMPTS = 3;
|
|
2806
3003
|
function hostnameOfHost(host) {
|
|
2807
3004
|
let hostname = host.trim().toLowerCase();
|
|
2808
3005
|
if (hostname.startsWith("[")) {
|
|
@@ -2892,6 +3089,49 @@ function createTraeShim(options) {
|
|
|
2892
3089
|
if (actual.length === expected.length && timingSafeEqual(actual, expected)) return route;
|
|
2893
3090
|
}
|
|
2894
3091
|
}
|
|
3092
|
+
/**
|
|
3093
|
+
* Run one chat request, failing over across pooled accounts when the account
|
|
3094
|
+
* that was chosen cannot serve it (issue #44).
|
|
3095
|
+
*
|
|
3096
|
+
* The happy path makes exactly one upstream call, so a pool that is switched
|
|
3097
|
+
* off — or on, with the first choice healthy — costs nothing but one cheap
|
|
3098
|
+
* ranking lookup. A failure is reported to the pool BEFORE the decision to
|
|
3099
|
+
* retry, because a rate limit the pool never hears about is a rate limit it
|
|
3100
|
+
* will walk straight back into on the next request.
|
|
3101
|
+
*/
|
|
3102
|
+
async function chatWithFailover(route, raw, signal) {
|
|
3103
|
+
const pool = route.pool;
|
|
3104
|
+
let client = route.client;
|
|
3105
|
+
let accountId = pool === void 0 ? void 0 : await pool.prepare().catch(() => void 0);
|
|
3106
|
+
const tried = [];
|
|
3107
|
+
for (let attempt = 0;; attempt += 1) {
|
|
3108
|
+
const result = await client.chatStream(raw, signal);
|
|
3109
|
+
if (result.ok) return {
|
|
3110
|
+
kind: "stream",
|
|
3111
|
+
response: result.response
|
|
3112
|
+
};
|
|
3113
|
+
const retryable = pool !== void 0 && !signal.aborted && FAILOVER_KINDS.has(result.kind);
|
|
3114
|
+
if (retryable && accountId !== void 0) await pool.failed(accountId, result.kind, result.message).catch(() => {});
|
|
3115
|
+
const failed = {
|
|
3116
|
+
kind: "failed",
|
|
3117
|
+
status: STATUS_BY_KIND[result.kind],
|
|
3118
|
+
code: result.kind,
|
|
3119
|
+
message: result.message
|
|
3120
|
+
};
|
|
3121
|
+
if (!retryable) return failed;
|
|
3122
|
+
if (accountId !== void 0) tried.push(accountId);
|
|
3123
|
+
const next = attempt + 1 >= MAX_POOL_ATTEMPTS ? void 0 : await pool.next(tried).catch(() => void 0);
|
|
3124
|
+
if (next === void 0) {
|
|
3125
|
+
const reason = await pool.shortfall(tried).catch(() => void 0);
|
|
3126
|
+
return reason === void 0 ? failed : {
|
|
3127
|
+
...failed,
|
|
3128
|
+
message: `${failed.message} (${reason})`
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
client = next.client;
|
|
3132
|
+
accountId = next.accountId;
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
2895
3135
|
async function handle(req, res) {
|
|
2896
3136
|
try {
|
|
2897
3137
|
if (!hostIsLoopback(req.headers.host)) return writeError(res, 403, "host_not_allowed", "Host must be loopback");
|
|
@@ -2931,8 +3171,8 @@ function createTraeShim(options) {
|
|
|
2931
3171
|
const abort = () => controller.abort();
|
|
2932
3172
|
req.once("aborted", abort);
|
|
2933
3173
|
req.socket.once("close", abort);
|
|
2934
|
-
const result = await route
|
|
2935
|
-
if (
|
|
3174
|
+
const result = await chatWithFailover(route, raw, controller.signal);
|
|
3175
|
+
if (result.kind === "failed") return writeError(res, result.status, result.code, result.message);
|
|
2936
3176
|
res.writeHead(200, {
|
|
2937
3177
|
"Content-Type": "text/event-stream",
|
|
2938
3178
|
"Cache-Control": "no-cache",
|
|
@@ -4975,13 +5215,37 @@ async function readProbeOutput(response) {
|
|
|
4975
5215
|
};
|
|
4976
5216
|
}
|
|
4977
5217
|
}
|
|
4978
|
-
/**
|
|
4979
|
-
|
|
5218
|
+
/**
|
|
5219
|
+
* The chat call one probe makes.
|
|
5220
|
+
*
|
|
5221
|
+
* `inputTokens` pads the user turn so the probe carries roughly as much text as
|
|
5222
|
+
* a real conversation. Size matters because an upstream that refuses LARGE
|
|
5223
|
+
* requests will happily answer a one-word probe, and a verdict earned by a
|
|
5224
|
+
* 1-token request says nothing about the 30k-token turn the user is about to
|
|
5225
|
+
* send. `0` keeps the historical minimal probe (one word) — that is the default
|
|
5226
|
+
* because it spends the least credit and answers the question the pool ranks on
|
|
5227
|
+
* ("can this account talk at all").
|
|
5228
|
+
*
|
|
5229
|
+
* The filler is `x` words: one ASCII word is about one token, so the body size
|
|
5230
|
+
* is predictable (tokens ≈ inputTokens × 2 characters) without a tokenizer.
|
|
5231
|
+
*/
|
|
5232
|
+
/**
|
|
5233
|
+
* The largest probe this plugin will pad.
|
|
5234
|
+
*
|
|
5235
|
+
* A 100k-token probe is already ~200 KB of JSON; anything above that is a request
|
|
5236
|
+
* the user's real turn would not plausibly reach in this product, and a config
|
|
5237
|
+
* file edited by hand must not turn one "test all" click into a multi-megabyte
|
|
5238
|
+
* upload.
|
|
5239
|
+
*/
|
|
5240
|
+
const MAX_PROBE_INPUT_TOKENS = 1e5;
|
|
5241
|
+
function probeBody(model, inputTokens = 0) {
|
|
5242
|
+
const padded = Math.min(Math.max(0, Math.floor(inputTokens)), MAX_PROBE_INPUT_TOKENS);
|
|
5243
|
+
const content = padded > 0 ? `ping ${"x ".repeat(padded).trimEnd()}` : "ping";
|
|
4980
5244
|
return JSON.stringify({
|
|
4981
5245
|
model,
|
|
4982
5246
|
messages: [{
|
|
4983
5247
|
role: "user",
|
|
4984
|
-
content
|
|
5248
|
+
content
|
|
4985
5249
|
}]
|
|
4986
5250
|
});
|
|
4987
5251
|
}
|
|
@@ -4990,11 +5254,11 @@ function probeBody(model) {
|
|
|
4990
5254
|
* recorded verdict describes the user's real path (same wire resolution, same
|
|
4991
5255
|
* function stamping, same error translation).
|
|
4992
5256
|
*/
|
|
4993
|
-
async function probeOneModel(chat, id, timeoutMs = TRAE_PROBE_TIMEOUT_MS) {
|
|
5257
|
+
async function probeOneModel(chat, id, timeoutMs = TRAE_PROBE_TIMEOUT_MS, inputTokens = 0) {
|
|
4994
5258
|
const controller = new AbortController();
|
|
4995
5259
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
4996
5260
|
try {
|
|
4997
|
-
const result = await chat(probeBody(id), controller.signal);
|
|
5261
|
+
const result = await chat(probeBody(id, inputTokens), controller.signal);
|
|
4998
5262
|
if (!result.ok) return {
|
|
4999
5263
|
id,
|
|
5000
5264
|
...classifyProbeResult(result, false)
|
|
@@ -5314,6 +5578,322 @@ var TraeUsageClient = class {
|
|
|
5314
5578
|
}
|
|
5315
5579
|
};
|
|
5316
5580
|
//#endregion
|
|
5581
|
+
//#region src/account-pool.ts
|
|
5582
|
+
/**
|
|
5583
|
+
* How long an account stays out of the pool when the upstream stated no time.
|
|
5584
|
+
*
|
|
5585
|
+
* A conservative FALLBACK, not a measurement: it applies whenever a failed
|
|
5586
|
+
* outcome carries no `retryAtMs` — every rate limit that came without a reset
|
|
5587
|
+
* sentence AND every transient failure (a transport error, a 5xx). In both cases
|
|
5588
|
+
* "when can I use this again" has no honest answer.
|
|
5589
|
+
*
|
|
5590
|
+
* The two failure directions it avoids: keeping the account in the pool would
|
|
5591
|
+
* re-bill it immediately and re-trip the same limit; dropping it FOREVER would
|
|
5592
|
+
* discard a working account over an event that said nothing about it. A short,
|
|
5593
|
+
* stated cooldown is the least-wrong of the two — and it is what lets the pool
|
|
5594
|
+
* heal itself, since nothing else re-probes an excluded member.
|
|
5595
|
+
*/
|
|
5596
|
+
const POOL_UNKNOWN_COOLDOWN_MS = 18e5;
|
|
5597
|
+
/**
|
|
5598
|
+
* Whether one account can be billed at `nowMs`.
|
|
5599
|
+
*
|
|
5600
|
+
* Returns the exclusion reason, or undefined when the account is a candidate.
|
|
5601
|
+
* The order matters and is NOT arbitrary:
|
|
5602
|
+
*
|
|
5603
|
+
* - A `credential-rejected` account is excluded regardless of any cooldown: its
|
|
5604
|
+
* token is not accepted, so waiting cannot help and the user must re-auth.
|
|
5605
|
+
* (`401` / `403` from Trae, and the `ExchangeToken` rejection of an expired
|
|
5606
|
+
* refresh token, land here.)
|
|
5607
|
+
* - A limited, unreachable or failed outcome is checked against its stated (or
|
|
5608
|
+
* fallback) cooldown, so an account whose limit has already reset — or whose
|
|
5609
|
+
* gateway blipped once — is back in the pool without needing another test.
|
|
5610
|
+
* - An `ok` measurement, and no measurement at all, are both candidates. The
|
|
5611
|
+
* latter matters: a freshly discovered account has never been tested and must
|
|
5612
|
+
* not be invisible.
|
|
5613
|
+
*
|
|
5614
|
+
* `not-found`, `credential-rejected` and `plan-gated` deliberately get NO
|
|
5615
|
+
* cooldown: one describes a model this channel does not serve (a catalog fact),
|
|
5616
|
+
* one needs the user to sign in again, and one needs a plan change. None is fixed
|
|
5617
|
+
* by waiting, and pretending otherwise would re-bill a state the upstream has
|
|
5618
|
+
* already refused.
|
|
5619
|
+
*
|
|
5620
|
+
* `policy-rejected` is NOT the `default` branch: a content-policy refusal is a
|
|
5621
|
+
* fact about ONE REQUEST, not about the account, so the account stays a candidate
|
|
5622
|
+
* exactly like `ok`. The measurement is still recorded so the card can explain
|
|
5623
|
+
* what happened.
|
|
5624
|
+
*
|
|
5625
|
+
* An outcome this build does not know becomes `unusable` rather than a candidate:
|
|
5626
|
+
* billing through a state we cannot interpret is worse than leaving the account
|
|
5627
|
+
* out until a later version explains it.
|
|
5628
|
+
*/
|
|
5629
|
+
function exclusionOf(probe, nowMs) {
|
|
5630
|
+
if (probe === void 0) return void 0;
|
|
5631
|
+
switch (probe.outcome) {
|
|
5632
|
+
case "ok": return;
|
|
5633
|
+
case "credential-rejected": return "credential-rejected";
|
|
5634
|
+
case "not-found": return "not-found";
|
|
5635
|
+
case "plan-gated": return "plan-gated";
|
|
5636
|
+
case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
|
|
5637
|
+
case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
|
|
5638
|
+
case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
|
|
5639
|
+
case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
|
|
5640
|
+
case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
|
|
5641
|
+
case "policy-rejected": return;
|
|
5642
|
+
default: return "unusable";
|
|
5643
|
+
}
|
|
5644
|
+
}
|
|
5645
|
+
/**
|
|
5646
|
+
* Whether an account's cooldown has elapsed (or was never stated).
|
|
5647
|
+
*
|
|
5648
|
+
* The fallback is the ONLY thing a transient outcome relies on: `retryAtMs` is
|
|
5649
|
+
* attached for a rate limit or a drained quota, never for a transport failure, so
|
|
5650
|
+
* `atMs + POOL_UNKNOWN_COOLDOWN_MS` is what brings those back.
|
|
5651
|
+
*
|
|
5652
|
+
* `fallbackMs` lets one outcome carry a SHORTER clock than the generic one. It is
|
|
5653
|
+
* a parameter rather than a second function so every outcome shares one definition
|
|
5654
|
+
* of "due".
|
|
5655
|
+
*/
|
|
5656
|
+
function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
|
|
5657
|
+
return nowMs >= (probe.retryAtMs ?? probe.atMs + fallbackMs);
|
|
5658
|
+
}
|
|
5659
|
+
/**
|
|
5660
|
+
* Rank a pool's members best-first.
|
|
5661
|
+
*
|
|
5662
|
+
* The rule, in priority order — this is the whole feature:
|
|
5663
|
+
*
|
|
5664
|
+
* 1. **Usability.** Only accounts that can be billed right now are candidates;
|
|
5665
|
+
* they always outrank every excluded one.
|
|
5666
|
+
* 2. **Expiring credits first, soonest first.** Points about to expire are worth
|
|
5667
|
+
* exactly zero after they do, so spending them before they lapse is strictly
|
|
5668
|
+
* better than saving them.
|
|
5669
|
+
* 3. **Largest expiring amount.** Between two accounts that both have expiring
|
|
5670
|
+
* points, the one with more at risk goes first.
|
|
5671
|
+
* 4. **Credits, highest first.** Among accounts with nothing expiring, spend the
|
|
5672
|
+
* one with the most, so a nearly-drained account is conserved.
|
|
5673
|
+
* 5. **Freshest credential.** Tie-break so a stale token never wins a draw.
|
|
5674
|
+
* 6. **Account id.** Final tie-break, making the order TOTAL: two accounts equal on
|
|
5675
|
+
* every key would otherwise keep the caller's array order, so the same pool
|
|
5676
|
+
* could rotate differently between two runs.
|
|
5677
|
+
*
|
|
5678
|
+
* WHY EXPIRY OUTRANKS SIZE (workbuddy issue #70). These used to be strict
|
|
5679
|
+
* lexicographic keys: total, THEN expiry. Since totals are almost never exactly
|
|
5680
|
+
* equal, the expiry rule was unreachable — an account with 196 points lapsing that
|
|
5681
|
+
* day ranked LAST behind accounts that expire weeks later, simply because it held
|
|
5682
|
+
* ~1,200 fewer points overall. It was never selected, so those points always
|
|
5683
|
+
* lapsed. A "use it or lose it" rule that another rule completely shadows is not a
|
|
5684
|
+
* tie-break, it is dead code.
|
|
5685
|
+
*
|
|
5686
|
+
* WHY SPENDING THE SMALL EXPIRING ACCOUNT IS STILL RIGHT. Credits are fungible, so
|
|
5687
|
+
* using the rich account instead does not preserve the 1 point — it spends the 1
|
|
5688
|
+
* point's chance and loses it. Using the expiring account spends a point that was
|
|
5689
|
+
* going to be lost anyway, and the rich account is still there for the next
|
|
5690
|
+
* request. The only real cost is a failover round-trip if the expiring account
|
|
5691
|
+
* cannot cover a whole request, which the exclusion/failover path already absorbs.
|
|
5692
|
+
*
|
|
5693
|
+
* Excluded accounts are still RETURNED (sorted after the candidates) so the card
|
|
5694
|
+
* can explain why each is out; they differ from candidates by `excludedBy` being
|
|
5695
|
+
* set, never by being silently dropped.
|
|
5696
|
+
*
|
|
5697
|
+
* Pure and total: the same input always yields the same order, and it never throws
|
|
5698
|
+
* on missing data (an account with no credits is ranked as 0, not crashed on).
|
|
5699
|
+
*/
|
|
5700
|
+
function rankPool(members, nowMs) {
|
|
5701
|
+
const rows = members.map((member) => {
|
|
5702
|
+
const excludedBy = exclusionOf(member.probe, nowMs);
|
|
5703
|
+
return {
|
|
5704
|
+
account: member.account,
|
|
5705
|
+
score: scoreOf(member),
|
|
5706
|
+
...excludedBy === void 0 ? {} : { excludedBy },
|
|
5707
|
+
_usable: excludedBy === void 0,
|
|
5708
|
+
_atRisk: (member.credits?.expiringSoon ?? 0) > 0,
|
|
5709
|
+
_expiringAmount: member.credits?.expiringSoon ?? 0,
|
|
5710
|
+
_expiring: member.credits?.nearestExpiryMs ?? Number.POSITIVE_INFINITY,
|
|
5711
|
+
_tokenExpiresAtMs: member.tokenExpiresAtMs ?? 0
|
|
5712
|
+
};
|
|
5713
|
+
});
|
|
5714
|
+
rows.sort((left, right) => {
|
|
5715
|
+
if (left._usable !== right._usable) return left._usable ? -1 : 1;
|
|
5716
|
+
if (left._atRisk !== right._atRisk) return left._atRisk ? -1 : 1;
|
|
5717
|
+
if (left._atRisk && right._atRisk && left._expiring !== right._expiring) return left._expiring - right._expiring;
|
|
5718
|
+
if (left._expiringAmount !== right._expiringAmount) return right._expiringAmount - left._expiringAmount;
|
|
5719
|
+
if (left.score !== right.score) return right.score - left.score;
|
|
5720
|
+
if (left._tokenExpiresAtMs !== right._tokenExpiresAtMs) return right._tokenExpiresAtMs - left._tokenExpiresAtMs;
|
|
5721
|
+
return left.account.id < right.account.id ? -1 : left.account.id > right.account.id ? 1 : 0;
|
|
5722
|
+
});
|
|
5723
|
+
return rows.map(({ account, score, excludedBy }) => ({
|
|
5724
|
+
account,
|
|
5725
|
+
score,
|
|
5726
|
+
...excludedBy === void 0 ? {} : { excludedBy }
|
|
5727
|
+
}));
|
|
5728
|
+
}
|
|
5729
|
+
/**
|
|
5730
|
+
* The checked ids that still resolve to a local sign-in, in the caller's order.
|
|
5731
|
+
*
|
|
5732
|
+
* This is THE definition of "effective members". It exists because the same
|
|
5733
|
+
* predicate wants to be written in several places (the status document, the batch
|
|
5734
|
+
* route's empty-pool guard, the host dependency the card reads) and "fixed one
|
|
5735
|
+
* end, missed the other" is this project's most productive bug shape, so the rule
|
|
5736
|
+
* has one home and every site delegates to it.
|
|
5737
|
+
*
|
|
5738
|
+
* ORDER IS THE CALLER'S: the answer preserves the order of `orderedIds`. Callers
|
|
5739
|
+
* pass whichever list's order they want to show — the saved list for the user's
|
|
5740
|
+
* own order, the store's roster for the account table's.
|
|
5741
|
+
*/
|
|
5742
|
+
function effectiveMembersOf(orderedIds, listedIds) {
|
|
5743
|
+
return orderedIds.filter((id) => listedIds.has(id));
|
|
5744
|
+
}
|
|
5745
|
+
/**
|
|
5746
|
+
* The members that may actually be billed, in the store's order.
|
|
5747
|
+
*
|
|
5748
|
+
* Three rules in one place, because a request path and a batch that each decide
|
|
5749
|
+
* "who counts" separately is how a pool ends up billing an account the user did
|
|
5750
|
+
* not opt into:
|
|
5751
|
+
*
|
|
5752
|
+
* - the SAVED opt-in list is the roster (`effectiveMembersOf` — an id the user
|
|
5753
|
+
* never selected, or one this machine no longer has, is not a candidate);
|
|
5754
|
+
* - `triedIds` are the accounts that already failed THIS request, so a failover
|
|
5755
|
+
* never walks back into the one that just refused it;
|
|
5756
|
+
* - a ghost (saved but not listed) is absent from `members`, so it drops out here
|
|
5757
|
+
* without a second check.
|
|
5758
|
+
*
|
|
5759
|
+
* An EMPTY `savedIds` yields an empty result on purpose: an enabled pool with no
|
|
5760
|
+
* members bills nobody, rather than silently meaning "all of them".
|
|
5761
|
+
*/
|
|
5762
|
+
function billableMembers(members, savedIds, triedIds = []) {
|
|
5763
|
+
const saved = new Set(effectiveMembersOf(savedIds, new Set(members.map((member) => member.account.id))));
|
|
5764
|
+
const tried = new Set(triedIds);
|
|
5765
|
+
return members.filter((member) => saved.has(member.account.id) && !tried.has(member.account.id));
|
|
5766
|
+
}
|
|
5767
|
+
/**
|
|
5768
|
+
* The account to bill, or undefined when the pool has no usable member.
|
|
5769
|
+
*
|
|
5770
|
+
* A thin selector over {@link rankPool} so the winner is decided in exactly one
|
|
5771
|
+
* place: every caller that needs "who serves now" gets the same answer.
|
|
5772
|
+
*/
|
|
5773
|
+
function pickAccount(members, nowMs) {
|
|
5774
|
+
return rankPool(members, nowMs).find((row) => row.excludedBy === void 0)?.account;
|
|
5775
|
+
}
|
|
5776
|
+
/**
|
|
5777
|
+
* The credit figure used for ordering.
|
|
5778
|
+
*
|
|
5779
|
+
* Absent credits rank as zero. Deliberately NOT "unknown ranks first": an account
|
|
5780
|
+
* whose balance could not be read must never outrank one we positively know is
|
|
5781
|
+
* full, or a failing credit read would capture all the traffic.
|
|
5782
|
+
*/
|
|
5783
|
+
function scoreOf(member) {
|
|
5784
|
+
return member.credits?.total ?? 0;
|
|
5785
|
+
}
|
|
5786
|
+
/**
|
|
5787
|
+
* Pick the free model to test against, from one panel's catalog.
|
|
5788
|
+
*
|
|
5789
|
+
* "Free" means `creditMultiplier === 0` EXACTLY. An absent multiplier is NOT free:
|
|
5790
|
+
* the CN static fallback catalog carries no multiplier at all, so treating absence
|
|
5791
|
+
* as zero would silently bill real credits on a "free" probe.
|
|
5792
|
+
*
|
|
5793
|
+
* Among the free candidates the LARGEST context window wins, because the probe
|
|
5794
|
+
* sends a real payload and a long-conversation probe is the point.
|
|
5795
|
+
*
|
|
5796
|
+
* Returns undefined when the panel has no free model, which the caller must render
|
|
5797
|
+
* as "none available" — never as a fallback to a paid model.
|
|
5798
|
+
*/
|
|
5799
|
+
function pickFreeModel(catalog) {
|
|
5800
|
+
let best;
|
|
5801
|
+
for (const model of catalog) {
|
|
5802
|
+
if (model.creditMultiplier !== 0) continue;
|
|
5803
|
+
if (best === void 0 || (model.contextWindow ?? 0) > (best.contextWindow ?? 0)) best = model;
|
|
5804
|
+
}
|
|
5805
|
+
return best;
|
|
5806
|
+
}
|
|
5807
|
+
/**
|
|
5808
|
+
* Resolve the model the pool should test against.
|
|
5809
|
+
*
|
|
5810
|
+
* A user-specified id always wins (it is a preference, not a computed default) and
|
|
5811
|
+
* is returned even when it is a PAID model: the card says so explicitly rather than
|
|
5812
|
+
* silently overriding the user's choice.
|
|
5813
|
+
*
|
|
5814
|
+
* The choice is VALIDATED against the panel's catalog, because the catalog changes:
|
|
5815
|
+
* the CN roster is empty until its first refresh, and models come and go. A saved
|
|
5816
|
+
* id that is no longer offered used to be returned as `preferred`, which let the
|
|
5817
|
+
* card enable its test button and the host run a batch against a model nobody
|
|
5818
|
+
* offers. It is reported as `stale` instead — never silently replaced by the free
|
|
5819
|
+
* model (that would test something the user did not choose) and never run.
|
|
5820
|
+
*/
|
|
5821
|
+
function resolveTargetModel(catalog, preferredId) {
|
|
5822
|
+
if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
|
|
5823
|
+
modelId: preferredId,
|
|
5824
|
+
source: "preferred"
|
|
5825
|
+
} : {
|
|
5826
|
+
staleModelId: preferredId,
|
|
5827
|
+
source: "stale"
|
|
5828
|
+
};
|
|
5829
|
+
const free = pickFreeModel(catalog);
|
|
5830
|
+
return free === void 0 ? { source: "none" } : {
|
|
5831
|
+
modelId: free.id,
|
|
5832
|
+
source: "free"
|
|
5833
|
+
};
|
|
5834
|
+
}
|
|
5835
|
+
/**
|
|
5836
|
+
* Map one Trae probe verdict onto the pool's outcome vocabulary.
|
|
5837
|
+
*
|
|
5838
|
+
* The mapping is the whole bridge between `src/model-probe.ts` (which answers a
|
|
5839
|
+
* QUESTION ABOUT A MODEL) and the pool (which must decide a FACT ABOUT AN ACCOUNT),
|
|
5840
|
+
* so every row states which side it is reading:
|
|
5841
|
+
*
|
|
5842
|
+
* - `available` → `ok`.
|
|
5843
|
+
* - `auth` → `credential-rejected`: 401/403 means THIS token is refused;
|
|
5844
|
+
* waiting cannot help and the user must sign in again.
|
|
5845
|
+
* - `gated` (`1005`) → `plan-gated`: the account's plan does not cover this model,
|
|
5846
|
+
* which is an ACCOUNT fact (a paying account may pass) but not one that heals on
|
|
5847
|
+
* a clock.
|
|
5848
|
+
* - `rate_limited` → `rate-limited`: `4011` or an honest 429 — transient by
|
|
5849
|
+
* definition, so it cools down rather than leaving the pool forever.
|
|
5850
|
+
* - `unsupported` → `not-found`: `4001` says no SOLO function serves this
|
|
5851
|
+
* `config_name`. It is MODEL-scoped, so the recorded `modelId` is what keeps the
|
|
5852
|
+
* row honest, and nothing about the account changes by waiting.
|
|
5853
|
+
* - `unknown` → `failed`: a timeout, a transport error or a proxy problem.
|
|
5854
|
+
* It cools down (it is not a statement about the account) and carries the
|
|
5855
|
+
* upstream text so the user can retest.
|
|
5856
|
+
*
|
|
5857
|
+
* There is deliberately NO Trae source for `model-busy`, `out-of-credit` and
|
|
5858
|
+
* `policy-rejected` in this table — they are produced by the live-request path
|
|
5859
|
+
* (`src/account-pool.ts` has no network), from `4008` (quota exhausted) and from a
|
|
5860
|
+
* content refusal. A verdict NOT in this table falls through to `failed`, so a new
|
|
5861
|
+
* upstream verdict can never silently mean "healthy".
|
|
5862
|
+
*/
|
|
5863
|
+
function outcomeOfVerdict(verdict) {
|
|
5864
|
+
switch (verdict) {
|
|
5865
|
+
case "available": return "ok";
|
|
5866
|
+
case "auth": return "credential-rejected";
|
|
5867
|
+
case "gated": return "plan-gated";
|
|
5868
|
+
case "rate_limited": return "rate-limited";
|
|
5869
|
+
case "unsupported": return "not-found";
|
|
5870
|
+
default: return "failed";
|
|
5871
|
+
}
|
|
5872
|
+
}
|
|
5873
|
+
/**
|
|
5874
|
+
* Which pool a panel's members belong to, for the on-disk probe store and the
|
|
5875
|
+
* `pool` settings slot.
|
|
5876
|
+
*
|
|
5877
|
+
* `enterprise` is its OWN bucket even though it shares the CN credential store:
|
|
5878
|
+
* an account that serves the personal SOLO channel says nothing about whether it
|
|
5879
|
+
* passes the tenant gate (`4019` is only revealed at the first chat), so measuring
|
|
5880
|
+
* one must not silently answer the other.
|
|
5881
|
+
*/
|
|
5882
|
+
function poolKeyOfPanel(panel) {
|
|
5883
|
+
return panel;
|
|
5884
|
+
}
|
|
5885
|
+
/**
|
|
5886
|
+
* WHY THERE IS NO SCHEDULER HERE.
|
|
5887
|
+
*
|
|
5888
|
+
* Like the reference implementation, this module carries no timer: rotation is a
|
|
5889
|
+
* PERSISTENT change of who pays while failover is a PER-REQUEST borrow of another
|
|
5890
|
+
* account, and having both with an automatic re-test on a clock made "who is being
|
|
5891
|
+
* billed right now" unanswerable on one card. Recovery is expressed as the cooldown
|
|
5892
|
+
* in {@link exclusionOf} instead, which needs no timer: it is evaluated at ranking
|
|
5893
|
+
* time, so a cooled-down member simply becomes a candidate again on the next
|
|
5894
|
+
* request.
|
|
5895
|
+
*/
|
|
5896
|
+
//#endregion
|
|
5317
5897
|
//#region src/transfer.ts
|
|
5318
5898
|
/**
|
|
5319
5899
|
* Account transfer: the export / import file format shared with sibling
|
|
@@ -5586,7 +6166,7 @@ function mergeTransferRecords(records, indexes, region, knownRegionOfCredential
|
|
|
5586
6166
|
* matched on the code rather than on the HTTP status: read as a transport
|
|
5587
6167
|
* failure it looks like the plugin is broken, when the day is simply spent.
|
|
5588
6168
|
*/
|
|
5589
|
-
const CHECKIN_DEVICE_ALREADY_CLAIMED = 9095;
|
|
6169
|
+
const CHECKIN_DEVICE_ALREADY_CLAIMED$1 = 9095;
|
|
5590
6170
|
/** Redact token-like content before it crosses to the browser. */
|
|
5591
6171
|
function safeMessage(error) {
|
|
5592
6172
|
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
@@ -5726,7 +6306,8 @@ function toCredits(snapshot, nowMs = Date.now()) {
|
|
|
5726
6306
|
displayDesc: pack.displayDesc,
|
|
5727
6307
|
remain: credit(Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))),
|
|
5728
6308
|
size: pack.creditsLimit ?? 0,
|
|
5729
|
-
...usableExpiry(pack.endTimeMs) ? { expiresAtMs: pack.endTimeMs } : {}
|
|
6309
|
+
...usableExpiry(pack.endTimeMs) ? { expiresAtMs: pack.endTimeMs } : {},
|
|
6310
|
+
...pack.availableEndpoint === void 0 ? {} : { endpoint: pack.availableEndpoint }
|
|
5730
6311
|
}));
|
|
5731
6312
|
const remaining = (endpoint) => snapshot.packs.filter((pack) => pack.availableEndpoint === endpoint).reduce((sum, pack) => credit(sum + Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))), 0);
|
|
5732
6313
|
return {
|
|
@@ -5880,18 +6461,21 @@ async function traeWebUsage(deps, panel) {
|
|
|
5880
6461
|
enabledModelIds: [...deps.enabledModelIds(panel)],
|
|
5881
6462
|
...deps.rawDiagnostic === void 0 ? {} : { rawChat: deps.rawDiagnostic(panel) }
|
|
5882
6463
|
};
|
|
6464
|
+
const poolSection = await poolSectionOf(deps, panel);
|
|
5883
6465
|
const client = deps.client(panel);
|
|
5884
6466
|
if (regionOfPanel(panel) === "ai") try {
|
|
5885
6467
|
const payStatus = await client.payStatus();
|
|
5886
6468
|
return {
|
|
5887
6469
|
status: "signed-in",
|
|
5888
6470
|
...account,
|
|
6471
|
+
...poolSection,
|
|
5889
6472
|
payStatus
|
|
5890
6473
|
};
|
|
5891
6474
|
} catch (error) {
|
|
5892
6475
|
return {
|
|
5893
6476
|
status: "signed-in",
|
|
5894
6477
|
...account,
|
|
6478
|
+
...poolSection,
|
|
5895
6479
|
payStatusError: safeMessage(error)
|
|
5896
6480
|
};
|
|
5897
6481
|
}
|
|
@@ -5909,6 +6493,7 @@ async function traeWebUsage(deps, panel) {
|
|
|
5909
6493
|
if (panel === "enterprise") return {
|
|
5910
6494
|
status: "signed-in",
|
|
5911
6495
|
...account,
|
|
6496
|
+
...poolSection,
|
|
5912
6497
|
creditsUnavailable: "enterprise"
|
|
5913
6498
|
};
|
|
5914
6499
|
const [snapshotResult, checkinResult] = await Promise.allSettled([client.snapshot(), client.checkinStatus()]);
|
|
@@ -5917,6 +6502,7 @@ async function traeWebUsage(deps, panel) {
|
|
|
5917
6502
|
return {
|
|
5918
6503
|
status: "signed-in",
|
|
5919
6504
|
...account,
|
|
6505
|
+
...poolSection,
|
|
5920
6506
|
...credits === void 0 ? { creditsError: snapshotResult.status === "rejected" ? safeMessage(snapshotResult.reason) : "unknown" } : { credits },
|
|
5921
6507
|
...checkinResult.status === "fulfilled" ? { checkin: toCheckin(checkinResult.value) } : { checkinError: safeMessage(checkinResult.reason) },
|
|
5922
6508
|
...alternatives === void 0 ? {} : { alternatives }
|
|
@@ -5957,6 +6543,185 @@ async function creditAlternatives(deps, panel, accounts) {
|
|
|
5957
6543
|
return alternatives.length === 0 ? void 0 : alternatives;
|
|
5958
6544
|
}
|
|
5959
6545
|
/**
|
|
6546
|
+
* The panel's pool section, or an empty standpoint when this Host cannot run one.
|
|
6547
|
+
*
|
|
6548
|
+
* THE rule of this file: the pool is an ADD-ON to the status document, so no
|
|
6549
|
+
* failure inside it may cost the user the sign-in panel. A member with a broken
|
|
6550
|
+
* balance read, a probe store that cannot be parsed, a clock — all of it degrades
|
|
6551
|
+
* to "no pool section" rather than to a 500 that hides the account table.
|
|
6552
|
+
*
|
|
6553
|
+
* What is deliberately NOT swallowed here: a `credential`-class failure of the
|
|
6554
|
+
* BOUND account, which `traeWebUsage` reports as `status: error` before the pool
|
|
6555
|
+
* is even assembled.
|
|
6556
|
+
*/
|
|
6557
|
+
async function poolSectionOf(deps, panel) {
|
|
6558
|
+
if (deps.pool === void 0) return {};
|
|
6559
|
+
try {
|
|
6560
|
+
const pool = await traeWebPool(deps, panel);
|
|
6561
|
+
return pool === void 0 ? {} : { pool };
|
|
6562
|
+
} catch {
|
|
6563
|
+
return {};
|
|
6564
|
+
}
|
|
6565
|
+
}
|
|
6566
|
+
/**
|
|
6567
|
+
* Assemble one panel's pool document (see {@link TraeWebPool}).
|
|
6568
|
+
*
|
|
6569
|
+
* Rows come from TWO sources and are kept in the Host's ranking order: checked
|
|
6570
|
+
* members (ranked by {@link rankPool}) first, then the local sign-ins the pool
|
|
6571
|
+
* does NOT cover, so the card can offer them to check. Unchecked rows carry no
|
|
6572
|
+
* credits and no measurement — nothing has been read for an account the pool does
|
|
6573
|
+
* not bill, and printing a friendlier-looking zero would be a lie.
|
|
6574
|
+
*
|
|
6575
|
+
* Every per-row read is failure-tolerant on purpose: one account whose balance
|
|
6576
|
+
* read throws must not remove the other members from the table.
|
|
6577
|
+
*/
|
|
6578
|
+
async function traeWebPool(deps, panel) {
|
|
6579
|
+
const pool = deps.pool;
|
|
6580
|
+
if (pool === void 0) return void 0;
|
|
6581
|
+
const preferences = pool.preferences(panel);
|
|
6582
|
+
const members = await pool.members(panel);
|
|
6583
|
+
const nowMs = Date.now();
|
|
6584
|
+
const ranked = rankPool(members, nowMs);
|
|
6585
|
+
const target = resolveTargetModel(pool.catalog?.(panel) ?? [], preferences.targetModelId);
|
|
6586
|
+
const byId = new Map(members.map((member) => [member.account.id, member]));
|
|
6587
|
+
const excludedById = new Map(ranked.flatMap((row) => row.excludedBy === void 0 ? [] : [[row.account.id, row.excludedBy]]));
|
|
6588
|
+
const rankedIds = new Set(ranked.map((row) => row.account.id));
|
|
6589
|
+
const others = (await pool.otherAccounts?.(panel) ?? []).filter((account) => !rankedIds.has(account.id));
|
|
6590
|
+
const checkinState = await pool.checkinState?.(panel).catch(() => ({})) ?? {};
|
|
6591
|
+
const memberNames = await pool.memberNames?.(panel).catch(() => ({})) ?? {};
|
|
6592
|
+
const currentAccountId = await pool.currentAccountId?.(panel).catch(() => void 0);
|
|
6593
|
+
/**
|
|
6594
|
+
* A row per listed account, members first.
|
|
6595
|
+
*
|
|
6596
|
+
* `member` is the membership source of truth: the saved list is what a batch
|
|
6597
|
+
* runs on, so a local sign-in that is not checked must not render as one —
|
|
6598
|
+
* otherwise the card would claim "N accounts will be tested" over a set the
|
|
6599
|
+
* Host never touches.
|
|
6600
|
+
*/
|
|
6601
|
+
const savedMembers = new Set(preferences.memberAccountIds ?? []);
|
|
6602
|
+
const rows = [];
|
|
6603
|
+
const rowIds = /* @__PURE__ */ new Set();
|
|
6604
|
+
for (const row of ranked) {
|
|
6605
|
+
rows.push({
|
|
6606
|
+
account: row.account,
|
|
6607
|
+
member: savedMembers.has(row.account.id)
|
|
6608
|
+
});
|
|
6609
|
+
rowIds.add(row.account.id);
|
|
6610
|
+
}
|
|
6611
|
+
for (const account of others) {
|
|
6612
|
+
rows.push({
|
|
6613
|
+
account,
|
|
6614
|
+
member: savedMembers.has(account.id)
|
|
6615
|
+
});
|
|
6616
|
+
rowIds.add(account.id);
|
|
6617
|
+
}
|
|
6618
|
+
for (const accountId of savedMembers) {
|
|
6619
|
+
if (rowIds.has(accountId)) continue;
|
|
6620
|
+
rows.push({
|
|
6621
|
+
account: {
|
|
6622
|
+
id: accountId,
|
|
6623
|
+
accountName: ""
|
|
6624
|
+
},
|
|
6625
|
+
member: true
|
|
6626
|
+
});
|
|
6627
|
+
rowIds.add(accountId);
|
|
6628
|
+
}
|
|
6629
|
+
const reachableIds = new Set(members.map((member) => member.account.id));
|
|
6630
|
+
const tokenExpiresById = /* @__PURE__ */ new Map();
|
|
6631
|
+
for (const member of members) if (member.tokenExpiresAtMs !== void 0) tokenExpiresById.set(member.account.id, member.tokenExpiresAtMs);
|
|
6632
|
+
const accounts = rows.map((row) => {
|
|
6633
|
+
const entry = byId.get(row.account.id);
|
|
6634
|
+
const measured = entry?.probe;
|
|
6635
|
+
const credits = entry?.credits;
|
|
6636
|
+
const checkin = checkinState[row.account.id];
|
|
6637
|
+
const excludedBy = excludedById.get(row.account.id);
|
|
6638
|
+
const accountName = row.account.accountName !== "" ? row.account.accountName : memberNames[row.account.id] ?? measured?.accountName ?? "";
|
|
6639
|
+
return {
|
|
6640
|
+
accountId: row.account.id,
|
|
6641
|
+
accountName,
|
|
6642
|
+
...credits === void 0 ? {} : {
|
|
6643
|
+
credits: credits.total,
|
|
6644
|
+
...credits.generalAvailable === void 0 ? {} : { generalAvailable: credits.generalAvailable },
|
|
6645
|
+
...credits.workAvailable === void 0 ? {} : { workAvailable: credits.workAvailable },
|
|
6646
|
+
expiringSoon: credits.expiringSoon,
|
|
6647
|
+
...credits.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: credits.nearestExpiryMs }
|
|
6648
|
+
},
|
|
6649
|
+
...measured === void 0 ? {} : { probe: {
|
|
6650
|
+
outcome: measured.outcome,
|
|
6651
|
+
atMs: measured.atMs,
|
|
6652
|
+
...measured.source === void 0 ? {} : { source: measured.source },
|
|
6653
|
+
...measured.modelId === void 0 ? {} : { modelId: measured.modelId },
|
|
6654
|
+
...measured.retryAtMs === void 0 ? {} : { retryAtMs: measured.retryAtMs },
|
|
6655
|
+
...measured.message === void 0 ? {} : { message: safeMessage(measured.message) }
|
|
6656
|
+
} },
|
|
6657
|
+
...excludedBy === void 0 ? {} : { excludedBy },
|
|
6658
|
+
...credits === void 0 && (tokenExpiresById.get(row.account.id) ?? Infinity) <= nowMs ? { tokenExpired: true } : {},
|
|
6659
|
+
current: currentAccountId !== void 0 && currentAccountId === row.account.id,
|
|
6660
|
+
member: row.member,
|
|
6661
|
+
reachable: reachableIds.has(row.account.id),
|
|
6662
|
+
...checkin === void 0 ? {} : {
|
|
6663
|
+
checkedInToday: checkin.checkedIn,
|
|
6664
|
+
...checkin.deviceCheckedIn === void 0 ? {} : { deviceCheckedIn: checkin.deviceCheckedIn }
|
|
6665
|
+
}
|
|
6666
|
+
};
|
|
6667
|
+
});
|
|
6668
|
+
/**
|
|
6669
|
+
* Why the pool cannot pick anybody, when it cannot.
|
|
6670
|
+
*
|
|
6671
|
+
* Only meaningful for an ENABLED pool: a disabled pool is not picking anybody by
|
|
6672
|
+
* design, and saying "no candidate" there would describe a fault that is not
|
|
6673
|
+
* one. Every member excluded is the case the card must explain, because traffic
|
|
6674
|
+
* then falls back to the saved selection while the table shows a full roster.
|
|
6675
|
+
*/
|
|
6676
|
+
let noCandidate;
|
|
6677
|
+
if (preferences.enabled === true) {
|
|
6678
|
+
if (members.length === 0) noCandidate = "empty";
|
|
6679
|
+
else if (ranked.every((row) => row.excludedBy !== void 0)) noCandidate = new Set(ranked.map((row) => row.excludedBy)).size === 1 ? ranked[0]?.excludedBy ?? "unusable" : "unusable";
|
|
6680
|
+
}
|
|
6681
|
+
const effectiveMemberAccountIds = effectiveMembersOf(rows.filter((row) => row.member).map((row) => row.account.id), new Set(members.map((member) => member.account.id)));
|
|
6682
|
+
return {
|
|
6683
|
+
enabled: preferences.enabled === true,
|
|
6684
|
+
checkinSupported: pool.checkin !== void 0 && regionOfPanel(panel) === "cn",
|
|
6685
|
+
...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
|
|
6686
|
+
targetModelSource: target.source,
|
|
6687
|
+
probeInputTokens: Math.max(0, preferences.probeInputTokens ?? 0),
|
|
6688
|
+
...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
|
|
6689
|
+
...preferences.memberAccountIds === void 0 ? {} : { memberAccountIds: preferences.memberAccountIds },
|
|
6690
|
+
effectiveMemberAccountIds,
|
|
6691
|
+
...Object.keys(memberNames).length === 0 ? {} : { memberNames },
|
|
6692
|
+
accounts,
|
|
6693
|
+
...noCandidate === void 0 ? {} : { noCandidate }
|
|
6694
|
+
};
|
|
6695
|
+
}
|
|
6696
|
+
/**
|
|
6697
|
+
* One test row, translated into the pool vocabulary the card renders.
|
|
6698
|
+
*
|
|
6699
|
+
* The runner answers in the model-probe verdicts (`available` / `auth` / `gated` /
|
|
6700
|
+
* `rate_limited` / `unsupported`); the card must not have to know that `gated` and
|
|
6701
|
+
* `1005` are the same event, so the translation happens here — once, for both the
|
|
6702
|
+
* batch and the single-row probe.
|
|
6703
|
+
*/
|
|
6704
|
+
function poolTestRowViewOf(row) {
|
|
6705
|
+
return {
|
|
6706
|
+
accountId: row.accountId,
|
|
6707
|
+
accountName: row.accountName,
|
|
6708
|
+
modelId: row.modelId,
|
|
6709
|
+
outcome: outcomeOfVerdict(row.outcome),
|
|
6710
|
+
detail: safeMessage(row.detail),
|
|
6711
|
+
...row.retryAtMs === void 0 ? {} : { retryAtMs: row.retryAtMs }
|
|
6712
|
+
};
|
|
6713
|
+
}
|
|
6714
|
+
/** One check-in row, redacted on the way out. */
|
|
6715
|
+
function poolCheckinRowViewOf(row) {
|
|
6716
|
+
return {
|
|
6717
|
+
accountId: row.accountId,
|
|
6718
|
+
accountName: row.accountName,
|
|
6719
|
+
status: row.status,
|
|
6720
|
+
...row.credit === void 0 ? {} : { credit: row.credit },
|
|
6721
|
+
...row.message === void 0 ? {} : { message: safeMessage(row.message) }
|
|
6722
|
+
};
|
|
6723
|
+
}
|
|
6724
|
+
/**
|
|
5960
6725
|
* The panel a request addresses, or a 400 answer. Absent parameter means the
|
|
5961
6726
|
* domestic tab; an unknown value is refused rather than guessed.
|
|
5962
6727
|
*/
|
|
@@ -6238,7 +7003,7 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
6238
7003
|
const outcome = await deps.claimCheckinOf(panel, accountId);
|
|
6239
7004
|
if (outcome === void 0) return json(res, 404, { error: "unknown account" });
|
|
6240
7005
|
const { claim, checkin: after } = outcome;
|
|
6241
|
-
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED;
|
|
7006
|
+
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED$1;
|
|
6242
7007
|
return json(res, 200, {
|
|
6243
7008
|
claimed: claim.claimed,
|
|
6244
7009
|
alreadyCheckedIn: after.checkedIn,
|
|
@@ -6261,13 +7026,13 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
6261
7026
|
claimed: false,
|
|
6262
7027
|
alreadyCheckedIn: false,
|
|
6263
7028
|
deviceCheckedIn: true,
|
|
6264
|
-
code: CHECKIN_DEVICE_ALREADY_CLAIMED,
|
|
7029
|
+
code: CHECKIN_DEVICE_ALREADY_CLAIMED$1,
|
|
6265
7030
|
message: "this device already used today's check-in",
|
|
6266
7031
|
checkin: toCheckin(current)
|
|
6267
7032
|
});
|
|
6268
7033
|
const claim = await client.claimCheckin();
|
|
6269
7034
|
const checkin = await client.checkinStatus();
|
|
6270
|
-
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED;
|
|
7035
|
+
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED$1;
|
|
6271
7036
|
json(res, 200, {
|
|
6272
7037
|
claimed: claim.claimed,
|
|
6273
7038
|
alreadyCheckedIn: false,
|
|
@@ -6281,7 +7046,131 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
6281
7046
|
}
|
|
6282
7047
|
}
|
|
6283
7048
|
});
|
|
7049
|
+
/**
|
|
7050
|
+
* The account pool's ONE mutation surface: `?action=checkin|test|remove`.
|
|
7051
|
+
*
|
|
7052
|
+
* Everything the pool does that costs something happens here and nowhere else —
|
|
7053
|
+
* `checkin` sends a claim, `test` spends a real chat call per member, `remove`
|
|
7054
|
+
* drops one account's measurement. The pool is therefore POST-only and
|
|
7055
|
+
* loopback-only, like the model test and the check-in it wraps.
|
|
7056
|
+
*
|
|
7057
|
+
* The empty-pool guard runs on `effectiveMemberAccountIds` — what this Host can
|
|
7058
|
+
* actually reach — rather than on the saved list. A saved id whose sign-in
|
|
7059
|
+
* disappeared would otherwise let a batch answer `200` with zero rows, which
|
|
7060
|
+
* the card reads as "全部正常".
|
|
7061
|
+
*
|
|
7062
|
+
* Answers are JSON except for a streaming test batch, which writes one NDJSON
|
|
7063
|
+
* line per account as it lands: the loop is serial, so without a stream a user
|
|
7064
|
+
* cannot tell a slow member from a dead button.
|
|
7065
|
+
*/
|
|
7066
|
+
const disposePool = ctx.webServer.register({
|
|
7067
|
+
kind: "exact",
|
|
7068
|
+
path: TRAE_POOL_PATH,
|
|
7069
|
+
handler: async (req, res) => {
|
|
7070
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
7071
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
7072
|
+
const panel = requestRegion(req, res);
|
|
7073
|
+
if (panel === void 0) return;
|
|
7074
|
+
const pool = deps.pool;
|
|
7075
|
+
if (pool === void 0) return json(res, 503, { error: "account pool unavailable" });
|
|
7076
|
+
const url = req.url ?? "/";
|
|
7077
|
+
const action = poolActionOf(url);
|
|
7078
|
+
if (action === void 0) return json(res, 400, { error: `missing or unknown action parameter (${TRAE_POOL_ACTION_PARAM})` });
|
|
7079
|
+
const accountId = poolAccountIdOf(url);
|
|
7080
|
+
if (accountId !== void 0 && !isTraeAccountId(accountId)) return json(res, 400, { error: "malformed account id" });
|
|
7081
|
+
if (action === "remove") {
|
|
7082
|
+
if (accountId === void 0) return json(res, 400, { error: "remove needs an accountId" });
|
|
7083
|
+
if (pool.removeOne === void 0) return json(res, 503, { error: "pool removal unavailable" });
|
|
7084
|
+
try {
|
|
7085
|
+
json(res, 200, {
|
|
7086
|
+
accountId,
|
|
7087
|
+
removed: await pool.removeOne(panel, accountId)
|
|
7088
|
+
});
|
|
7089
|
+
} catch (error) {
|
|
7090
|
+
json(res, 500, { error: safeMessage(error) });
|
|
7091
|
+
}
|
|
7092
|
+
return;
|
|
7093
|
+
}
|
|
7094
|
+
/**
|
|
7095
|
+
* Both remaining actions need a NON-EMPTY effective pool.
|
|
7096
|
+
*
|
|
7097
|
+
* `effectiveMemberAccountIds` is optional; when the Host does not supply it
|
|
7098
|
+
* the guard falls back to the saved list, which is weaker but never worse
|
|
7099
|
+
* than no guard at all. `no-live-members` and `no-members` are separate
|
|
7100
|
+
* reasons because the advice differs: re-check the accounts you lost versus
|
|
7101
|
+
* check some accounts in.
|
|
7102
|
+
*/
|
|
7103
|
+
const saved = pool.preferences(panel).memberAccountIds ?? [];
|
|
7104
|
+
let effective = saved;
|
|
7105
|
+
if (pool.effectiveMemberAccountIds !== void 0) try {
|
|
7106
|
+
effective = await pool.effectiveMemberAccountIds(panel);
|
|
7107
|
+
} catch (error) {
|
|
7108
|
+
return json(res, 500, { error: safeMessage(error) });
|
|
7109
|
+
}
|
|
7110
|
+
if (effective.length === 0) return json(res, 409, {
|
|
7111
|
+
action,
|
|
7112
|
+
reason: saved.length === 0 ? "no-members" : "no-live-members",
|
|
7113
|
+
error: saved.length === 0 ? "no accounts are checked into this pool" : "none of the checked accounts can be reached on this machine"
|
|
7114
|
+
});
|
|
7115
|
+
if (action === "checkin") {
|
|
7116
|
+
const supported = pool.checkinSupported?.(panel) ?? true;
|
|
7117
|
+
if (!supported || pool.checkin === void 0) return json(res, 409, {
|
|
7118
|
+
action,
|
|
7119
|
+
reason: "checkin-unsupported",
|
|
7120
|
+
error: supported ? "check-in is unavailable in this build" : "this panel has no daily check-in"
|
|
7121
|
+
});
|
|
7122
|
+
try {
|
|
7123
|
+
json(res, 200, { rows: (await pool.checkin(panel)).map((row) => poolCheckinRowViewOf(row)) });
|
|
7124
|
+
} catch (error) {
|
|
7125
|
+
json(res, 500, { error: safeMessage(error) });
|
|
7126
|
+
}
|
|
7127
|
+
return;
|
|
7128
|
+
}
|
|
7129
|
+
const targetModelId = pool.preferences(panel).targetModelId;
|
|
7130
|
+
if (pool.test === void 0) return json(res, 409, {
|
|
7131
|
+
action,
|
|
7132
|
+
reason: "test-unsupported",
|
|
7133
|
+
error: "the pool cannot test this panel"
|
|
7134
|
+
});
|
|
7135
|
+
const single = accountId === void 0 ? void 0 : pool.testOne;
|
|
7136
|
+
if (single !== void 0 && accountId !== void 0) {
|
|
7137
|
+
const resolved = resolveTargetModel(pool.catalog?.(panel) ?? [], targetModelId);
|
|
7138
|
+
if (resolved.modelId === void 0) return json(res, 409, {
|
|
7139
|
+
action,
|
|
7140
|
+
reason: resolved.source === "stale" ? "stale-target" : "no-target-model",
|
|
7141
|
+
error: resolved.source === "stale" ? "the saved test model is no longer offered by this panel" : "no free model is available to test against"
|
|
7142
|
+
});
|
|
7143
|
+
resolved.modelId;
|
|
7144
|
+
}
|
|
7145
|
+
if (single !== void 0 && accountId !== void 0) try {
|
|
7146
|
+
const row = await single(panel, accountId);
|
|
7147
|
+
if (row === void 0) return json(res, 404, { error: "unknown account" });
|
|
7148
|
+
return json(res, 200, { rows: [poolTestRowViewOf(row)] });
|
|
7149
|
+
} catch (error) {
|
|
7150
|
+
return json(res, 500, { error: safeMessage(error) });
|
|
7151
|
+
}
|
|
7152
|
+
res.writeHead(200, {
|
|
7153
|
+
"Content-Type": "application/x-ndjson",
|
|
7154
|
+
"Cache-Control": "no-store"
|
|
7155
|
+
});
|
|
7156
|
+
const writeLine = (value) => {
|
|
7157
|
+
res.write(`${JSON.stringify(value)}\n`);
|
|
7158
|
+
};
|
|
7159
|
+
try {
|
|
7160
|
+
writeLine({ rows: (await pool.test(panel, targetModelId ?? "", (row) => {
|
|
7161
|
+
writeLine({ row: poolTestRowViewOf(row) });
|
|
7162
|
+
})).map((row) => poolTestRowViewOf(row)) });
|
|
7163
|
+
} catch (error) {
|
|
7164
|
+
writeLine({
|
|
7165
|
+
reason: "pool-failed",
|
|
7166
|
+
error: safeMessage(error)
|
|
7167
|
+
});
|
|
7168
|
+
}
|
|
7169
|
+
res.end();
|
|
7170
|
+
}
|
|
7171
|
+
});
|
|
6284
7172
|
return () => {
|
|
7173
|
+
disposePool();
|
|
6285
7174
|
disposeCheckin();
|
|
6286
7175
|
disposeRefresh();
|
|
6287
7176
|
disposeTransfer();
|
|
@@ -6294,6 +7183,538 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
6294
7183
|
}, "dsh-connect-trae: Web usage route");
|
|
6295
7184
|
}
|
|
6296
7185
|
//#endregion
|
|
7186
|
+
//#region src/account-pool-run.ts
|
|
7187
|
+
/** The upstream's business code for "this DEVICE already checked in today". */
|
|
7188
|
+
const CHECKIN_DEVICE_ALREADY_CLAIMED = 9095;
|
|
7189
|
+
/**
|
|
7190
|
+
* Check in every account, one at a time.
|
|
7191
|
+
*
|
|
7192
|
+
* Idempotent by design (see the module note): an account already checked in today
|
|
7193
|
+
* is reported as `already` and NO write is attempted. A failure on one account is
|
|
7194
|
+
* recorded on that row and the batch continues — EXCEPT for the device-level
|
|
7195
|
+
* refusal (`9095`), which stops the run: it is a fact about this MACHINE, so every
|
|
7196
|
+
* later account would hit the same wall and each of those calls is a wasted
|
|
7197
|
+
* upstream request against an endpoint that just said "stop".
|
|
7198
|
+
*
|
|
7199
|
+
* Never throws: every problem becomes a row.
|
|
7200
|
+
*/
|
|
7201
|
+
async function checkinAllAccounts(targets, deps) {
|
|
7202
|
+
const rows = [];
|
|
7203
|
+
for (const [index, target] of targets.entries()) {
|
|
7204
|
+
if (index > 0) await (deps.wait ?? defaultWait)(400);
|
|
7205
|
+
const row = await checkinOne(target, deps);
|
|
7206
|
+
rows.push(row);
|
|
7207
|
+
if (row.status === "device-already") {
|
|
7208
|
+
for (const skipped of targets.slice(index + 1)) rows.push({
|
|
7209
|
+
accountId: skipped.accountId,
|
|
7210
|
+
accountName: skipped.accountName,
|
|
7211
|
+
status: "device-already",
|
|
7212
|
+
message: "skipped: this device has already claimed today"
|
|
7213
|
+
});
|
|
7214
|
+
break;
|
|
7215
|
+
}
|
|
7216
|
+
}
|
|
7217
|
+
return rows;
|
|
7218
|
+
}
|
|
7219
|
+
/** One account's check-in, reduced to its row. */
|
|
7220
|
+
async function checkinOne(target, deps) {
|
|
7221
|
+
const base = {
|
|
7222
|
+
accountId: target.accountId,
|
|
7223
|
+
accountName: target.accountName
|
|
7224
|
+
};
|
|
7225
|
+
let credential;
|
|
7226
|
+
try {
|
|
7227
|
+
credential = await deps.credentialFor(target.accountId);
|
|
7228
|
+
} catch (error) {
|
|
7229
|
+
return {
|
|
7230
|
+
...base,
|
|
7231
|
+
status: "failed",
|
|
7232
|
+
message: messageOf(error)
|
|
7233
|
+
};
|
|
7234
|
+
}
|
|
7235
|
+
if (credential === void 0) return {
|
|
7236
|
+
...base,
|
|
7237
|
+
status: "failed",
|
|
7238
|
+
message: "no stored credential for this account"
|
|
7239
|
+
};
|
|
7240
|
+
let status;
|
|
7241
|
+
try {
|
|
7242
|
+
status = await deps.checkinStatusOf(credential);
|
|
7243
|
+
} catch (error) {
|
|
7244
|
+
return {
|
|
7245
|
+
...base,
|
|
7246
|
+
status: "failed",
|
|
7247
|
+
message: messageOf(error)
|
|
7248
|
+
};
|
|
7249
|
+
}
|
|
7250
|
+
if (!status.enabled) return {
|
|
7251
|
+
...base,
|
|
7252
|
+
status: "disabled",
|
|
7253
|
+
message: "check-in is not enabled for this account"
|
|
7254
|
+
};
|
|
7255
|
+
if (status.checkedIn) return {
|
|
7256
|
+
...base,
|
|
7257
|
+
status: "already",
|
|
7258
|
+
credit: status.credits
|
|
7259
|
+
};
|
|
7260
|
+
if (status.didCheckedIn) {}
|
|
7261
|
+
try {
|
|
7262
|
+
const claim = await deps.claimCheckinOf(credential);
|
|
7263
|
+
if (claim.claimed) return {
|
|
7264
|
+
...base,
|
|
7265
|
+
status: "claimed",
|
|
7266
|
+
credit: status.credits,
|
|
7267
|
+
...nonEmpty(claim.message)
|
|
7268
|
+
};
|
|
7269
|
+
if (claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED) return {
|
|
7270
|
+
...base,
|
|
7271
|
+
status: "device-already",
|
|
7272
|
+
message: claim.message
|
|
7273
|
+
};
|
|
7274
|
+
return {
|
|
7275
|
+
...base,
|
|
7276
|
+
status: "failed",
|
|
7277
|
+
message: claim.message === "" ? `code ${String(claim.code)}` : claim.message
|
|
7278
|
+
};
|
|
7279
|
+
} catch (error) {
|
|
7280
|
+
return {
|
|
7281
|
+
...base,
|
|
7282
|
+
status: "failed",
|
|
7283
|
+
message: messageOf(error)
|
|
7284
|
+
};
|
|
7285
|
+
}
|
|
7286
|
+
}
|
|
7287
|
+
/**
|
|
7288
|
+
* Test every account against one model, one at a time.
|
|
7289
|
+
*
|
|
7290
|
+
* The model is resolved by the caller (see `resolveTargetModel`) so this function
|
|
7291
|
+
* never has to decide "free or not" — an unresolvable target is the caller's
|
|
7292
|
+
* problem to report, and testing against a model nobody chose would be exactly the
|
|
7293
|
+
* silent spend this feature forbids.
|
|
7294
|
+
*
|
|
7295
|
+
* ONE model for the whole batch, deliberately. Letting each row test a different
|
|
7296
|
+
* model makes a green batch UNCOMPARABLE — one account's `ok` on model X and
|
|
7297
|
+
* another's on model Y — which is the reading ("all green") that the reference
|
|
7298
|
+
* implementation removed its per-account override for. What stays is that every
|
|
7299
|
+
* measurement RECORDS the model it probed and each row displays it.
|
|
7300
|
+
*
|
|
7301
|
+
* Never throws: a failed probe is a ROW, not an aborted batch. A credential
|
|
7302
|
+
* rejection does NOT stop the run (unlike the region-wide model test, where every
|
|
7303
|
+
* later probe would fail the same way): here each account has its OWN credential,
|
|
7304
|
+
* so one account's bad token says nothing about its neighbours.
|
|
7305
|
+
*
|
|
7306
|
+
* `onRow` fires the moment one account's row exists, which is what lets a caller
|
|
7307
|
+
* report progress. The loop is SERIAL by design, so without this a caller cannot
|
|
7308
|
+
* say anything until the SLOWEST member answers: one stuck account made a working
|
|
7309
|
+
* batch look like a dead button.
|
|
7310
|
+
*/
|
|
7311
|
+
async function testAllAccounts(targets, modelId, probe, deps, onRow) {
|
|
7312
|
+
const rows = [];
|
|
7313
|
+
for (const [index, target] of targets.entries()) {
|
|
7314
|
+
if (index > 0) await (deps.wait ?? defaultWait)(400);
|
|
7315
|
+
const row = await testOne(target, modelId, probe, deps);
|
|
7316
|
+
rows.push(row);
|
|
7317
|
+
onRow?.(row);
|
|
7318
|
+
}
|
|
7319
|
+
return rows;
|
|
7320
|
+
}
|
|
7321
|
+
/** One account's probe, reduced to its row. */
|
|
7322
|
+
async function testOne(target, modelId, probe, deps) {
|
|
7323
|
+
const base = {
|
|
7324
|
+
accountId: target.accountId,
|
|
7325
|
+
accountName: target.accountName,
|
|
7326
|
+
modelId
|
|
7327
|
+
};
|
|
7328
|
+
let credential;
|
|
7329
|
+
try {
|
|
7330
|
+
credential = await deps.credentialFor(target.accountId);
|
|
7331
|
+
} catch (error) {
|
|
7332
|
+
return {
|
|
7333
|
+
...base,
|
|
7334
|
+
outcome: "auth",
|
|
7335
|
+
detail: messageOf(error)
|
|
7336
|
+
};
|
|
7337
|
+
}
|
|
7338
|
+
if (credential === void 0) return {
|
|
7339
|
+
...base,
|
|
7340
|
+
outcome: "auth",
|
|
7341
|
+
detail: "no stored credential for this account"
|
|
7342
|
+
};
|
|
7343
|
+
const result = await probe(deps.chatFor, credential, modelId);
|
|
7344
|
+
return {
|
|
7345
|
+
...base,
|
|
7346
|
+
outcome: result.verdict,
|
|
7347
|
+
detail: result.detail
|
|
7348
|
+
};
|
|
7349
|
+
}
|
|
7350
|
+
/**
|
|
7351
|
+
* Turn a test batch's rows into the per-account measurements to remember.
|
|
7352
|
+
*
|
|
7353
|
+
* A row is stored only when the probe actually measured the MODEL. A row that
|
|
7354
|
+
* failed because the credential could not even be read says nothing about the
|
|
7355
|
+
* model, and recording it would exclude a possibly-perfect account from the pool —
|
|
7356
|
+
* so those accounts keep their previous measurement instead.
|
|
7357
|
+
*
|
|
7358
|
+
* `atMs` is stamped here, once for the batch, so the card's "tested N minutes ago"
|
|
7359
|
+
* reflects when the batch finished rather than drifting per row.
|
|
7360
|
+
*
|
|
7361
|
+
* `detail` is carried through as `message`. It was already produced from the
|
|
7362
|
+
* upstream's own (translated) text, so persisting it cannot leak a token, and
|
|
7363
|
+
* without it the pool table could only repeat one generic sentence for a DNS
|
|
7364
|
+
* failure, a gateway 502 and a cancelled request alike.
|
|
7365
|
+
*/
|
|
7366
|
+
function probeUpdatesOf(rows, nowMs = Date.now()) {
|
|
7367
|
+
const updates = {};
|
|
7368
|
+
for (const row of rows) {
|
|
7369
|
+
if (isTransportFailure(row)) continue;
|
|
7370
|
+
const outcome = outcomeOfVerdict(row.outcome);
|
|
7371
|
+
updates[row.accountId] = {
|
|
7372
|
+
outcome,
|
|
7373
|
+
atMs: nowMs,
|
|
7374
|
+
source: "test-batch",
|
|
7375
|
+
modelId: row.modelId,
|
|
7376
|
+
...row.accountName === "" ? {} : { accountName: row.accountName },
|
|
7377
|
+
...row.retryAtMs === void 0 ? {} : { retryAtMs: row.retryAtMs },
|
|
7378
|
+
...row.detail === "" ? {} : { message: row.detail }
|
|
7379
|
+
};
|
|
7380
|
+
}
|
|
7381
|
+
return updates;
|
|
7382
|
+
}
|
|
7383
|
+
/**
|
|
7384
|
+
* Whether a row measures the transport rather than the account/model.
|
|
7385
|
+
*
|
|
7386
|
+
* `auth` carrying the pool's own "no stored credential" message is the one case we
|
|
7387
|
+
* can attribute to ourselves rather than the upstream: the request never left.
|
|
7388
|
+
* Every other rejection came from the upstream and IS a statement about this
|
|
7389
|
+
* account, so it is kept.
|
|
7390
|
+
*/
|
|
7391
|
+
function isTransportFailure(row) {
|
|
7392
|
+
return row.outcome === "auth" && row.detail.startsWith("no stored credential");
|
|
7393
|
+
}
|
|
7394
|
+
/** Carry an optional upstream sentence only when it says something. */
|
|
7395
|
+
function nonEmpty(message) {
|
|
7396
|
+
return message === "" ? {} : { message };
|
|
7397
|
+
}
|
|
7398
|
+
/** The default inter-account pause. */
|
|
7399
|
+
function defaultWait(ms) {
|
|
7400
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7401
|
+
}
|
|
7402
|
+
/** A safe message from an unknown throwable, bounded in length. */
|
|
7403
|
+
function messageOf(error) {
|
|
7404
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
7405
|
+
}
|
|
7406
|
+
//#endregion
|
|
7407
|
+
//#region src/reset-time.ts
|
|
7408
|
+
/**
|
|
7409
|
+
* Parse a reset TIME out of an upstream failure message, when the upstream stated
|
|
7410
|
+
* one.
|
|
7411
|
+
*
|
|
7412
|
+
* 参考:`dsh-connect-workbuddy` 的 `src/probe.ts`(`parseUpstreamResetAt`)。
|
|
7413
|
+
* 该实现在 WorkBuddy 上是实测出来的(其网关的 429 正文里带一句「将在 … 重置」
|
|
7414
|
+
* 与一句英文 `will reset at …`),本文件把同一套解析规则搬过来,并把「偏移量
|
|
7415
|
+
* 必须显式」这条硬约束原样保留。
|
|
7416
|
+
* 改动:诚实标注**证据状态**。Trae 目前被记录在案的限流/配额文案里**没有任何一条
|
|
7417
|
+
* 带时间**:
|
|
7418
|
+
*
|
|
7419
|
+
* - `4011` → `We're sorry, your requests have exceeded the rate limit.
|
|
7420
|
+
* Please wait and try again later.`(docs/ISSUE33_DIAGNOSIS.md:30,实测原文)
|
|
7421
|
+
* - `4008` → `Your requests have exceeded the quota.`(同文档:55,实测原文)
|
|
7422
|
+
*
|
|
7423
|
+
* 所以本函数在 Trae 上大概率永远返回 `undefined`,而**这正是它应有的行为**:
|
|
7424
|
+
* 没有给出的时间不能变成插件自己编的倒计时(池会退回
|
|
7425
|
+
* `POOL_UNKNOWN_COOLDOWN_MS` 的 30 分钟兜底并在卡片上如实显示「上游未给出
|
|
7426
|
+
* 何时恢复」)。它存在的理由只有一个:Trae 的中文网关将来若在正文里写了时间
|
|
7427
|
+
* (与 WorkBuddy 中文网关同一族文案),不要把一个真实存在的时间当没有解析出来
|
|
7428
|
+
* 丢掉 —— 参考实现里那是一条实测的 bug(英文正文解析不到 → 账号比上游要求的
|
|
7429
|
+
* 早 ~18 小时回到池里,再花一次请求重新撞上同一个限制)。
|
|
7430
|
+
*
|
|
7431
|
+
* NEVER use it for a credit PACK's `endTimeMs`: that is when a balance STOPS
|
|
7432
|
+
* being usable, not when a new allowance ARRIVES. Attaching it as a retry time
|
|
7433
|
+
* would put an exhausted account back into rotation at the exact moment its
|
|
7434
|
+
* credit disappears.
|
|
7435
|
+
*
|
|
7436
|
+
* @module dsh-connect-trae/reset-time
|
|
7437
|
+
*/
|
|
7438
|
+
/**
|
|
7439
|
+
* The timestamp shape both spellings share: `YYYY-MM-DD HH:MM:SS` followed by an
|
|
7440
|
+
* EXPLICIT UTC offset.
|
|
7441
|
+
*
|
|
7442
|
+
* The offset is required, not optional. `2026-09-30 02:30:30` with no zone is a
|
|
7443
|
+
* moment the plugin would have to guess at, and `Date.parse` guesses LOCAL time —
|
|
7444
|
+
* right on a UTC+8 machine by accident, silently wrong everywhere else. A message
|
|
7445
|
+
* that names a time without a zone therefore yields no time, which is the honest
|
|
7446
|
+
* answer.
|
|
7447
|
+
*/
|
|
7448
|
+
const RESET_STAMP = "(\\d{4})-(\\d{2})-(\\d{2})\\s+(\\d{2}):(\\d{2}):(\\d{2})\\s*UTC\\s*([+-])(\\d{1,2})(?::?(\\d{2}))?";
|
|
7449
|
+
/** Chinese spelling: the marker sits AFTER the timestamp (将在 … 重置). */
|
|
7450
|
+
const UPSTREAM_RESET_PATTERN_ZH = new RegExp(`将在?\\s*${RESET_STAMP}[\\s\\S]*?重置`, "u");
|
|
7451
|
+
/**
|
|
7452
|
+
* English spelling: the marker sits BEFORE the timestamp (`reset at` /
|
|
7453
|
+
* `refresh on`). It needs its own pattern because the two languages put the
|
|
7454
|
+
* keyword on different sides of the time, so loosening the surrounding prose
|
|
7455
|
+
* alone cannot cover both.
|
|
7456
|
+
*/
|
|
7457
|
+
const UPSTREAM_RESET_PATTERN_EN = new RegExp(`(?:reset|resets|refresh|refreshes|renew|renews)\\s+(?:at|on)\\s+${RESET_STAMP}`, "iu");
|
|
7458
|
+
/**
|
|
7459
|
+
* Parse the reset time out of an upstream failure message.
|
|
7460
|
+
*
|
|
7461
|
+
* Returns epoch ms, or undefined when the message names no parsable time — the
|
|
7462
|
+
* caller must then say "the upstream gave no time", never invent one.
|
|
7463
|
+
*
|
|
7464
|
+
* THE OFFSET IS HONOURED, NOT ASSUMED. `2026-09-30 02:30:30 UTC+8` is 18:30:30 UTC
|
|
7465
|
+
* the previous day, so the components are assembled with `Date.UTC` and the stated
|
|
7466
|
+
* offset subtracted — correct on every host regardless of its own zone.
|
|
7467
|
+
*
|
|
7468
|
+
* A whole-second resolution is what the upstream prints; sub-second precision would
|
|
7469
|
+
* be fiction in the other direction.
|
|
7470
|
+
*/
|
|
7471
|
+
function parseUpstreamResetAt(message) {
|
|
7472
|
+
const match = UPSTREAM_RESET_PATTERN_ZH.exec(message) ?? UPSTREAM_RESET_PATTERN_EN.exec(message);
|
|
7473
|
+
if (match === null) return void 0;
|
|
7474
|
+
const [, year, month, day, hour, minute, second, sign, offsetHours, offsetMinutes] = match;
|
|
7475
|
+
const offsetTotalMinutes = (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes ?? "0"));
|
|
7476
|
+
const utcMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
|
|
7477
|
+
const parsed = new Date(utcMs);
|
|
7478
|
+
if (parsed.getUTCFullYear() !== Number(year) || parsed.getUTCMonth() !== Number(month) - 1 || parsed.getUTCDate() !== Number(day) || parsed.getUTCHours() !== Number(hour) || parsed.getUTCMinutes() !== Number(minute) || parsed.getUTCSeconds() !== Number(second)) return;
|
|
7479
|
+
if (Math.abs(offsetTotalMinutes) > 840) return void 0;
|
|
7480
|
+
return utcMs - offsetTotalMinutes * 6e4;
|
|
7481
|
+
}
|
|
7482
|
+
//#endregion
|
|
7483
|
+
//#region src/account-pool-store.ts
|
|
7484
|
+
/**
|
|
7485
|
+
* The account pool's RUNTIME store: measured facts, on disk, outside settings.
|
|
7486
|
+
*
|
|
7487
|
+
* 参考:`dsh-connect-workbuddy` 的 `src/account-pool-store.ts`,以及本仓库
|
|
7488
|
+
* `src/auth.ts` 的插件自有文件约定(`$DSH_HOME` 下的 `.trae-auth.<region>.json`,
|
|
7489
|
+
* `withFileLock` + `writeFileAtomic`,`mode: 0o600`)。
|
|
7490
|
+
* 改动:一份、按**面板**分文件(cn / enterprise / ai),因为 enterprise 与 cn 共用
|
|
7491
|
+
* 同一份凭据却走不同的网关 —— 一个账号在个人 SOLO 通道能用,完全不说明它过得了
|
|
7492
|
+
* 租户门(`4019` 只在第一次真实对话时暴露),所以两份测量必须分开落盘。
|
|
7493
|
+
*
|
|
7494
|
+
* 为什么这些事实不放 settings(`regions[panel].pool`):
|
|
7495
|
+
*
|
|
7496
|
+
* 卡片对用户偏好用的是「草稿 → 保存 → 丢弃草稿」模式:保存成功会丢弃草稿。
|
|
7497
|
+
* 如果探测结果也住在那个槽里,用户手工点一次「保存」就会用**几分钟前的草稿**
|
|
7498
|
+
* 覆盖掉刚刚写进去的测量结果 —— 那等于把测量回退,测试白做。
|
|
7499
|
+
* 观测数据由插件高频写入、且不是用户偏好,所以它必须落在自己的文件里。
|
|
7500
|
+
*
|
|
7501
|
+
* 本模块的读写**从不抛异常给调用方**:池是增强功能,一个损坏的观测文件绝不能让
|
|
7502
|
+
* 卡片或 host 启动失败 —— 最坏情况是「回到未测试」,而不是整个插件不可用。
|
|
7503
|
+
*
|
|
7504
|
+
* @module dsh-connect-trae/account-pool-store
|
|
7505
|
+
*/
|
|
7506
|
+
/** On-disk format version; readers reject anything else. */
|
|
7507
|
+
const STORE_FORMAT_VERSION = 1;
|
|
7508
|
+
/** Filename prefix for one panel's measured pool facts. */
|
|
7509
|
+
const POOL_STORE_PREFIX = ".trae-pool";
|
|
7510
|
+
/** Whether a parsed value looks like a stored probe. */
|
|
7511
|
+
function isProbe(value) {
|
|
7512
|
+
if (typeof value !== "object" || value === null) return false;
|
|
7513
|
+
const candidate = value;
|
|
7514
|
+
return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["accountName"] === void 0 || typeof candidate["accountName"] === "string") && (candidate["modelId"] === void 0 || typeof candidate["modelId"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
|
|
7515
|
+
}
|
|
7516
|
+
/**
|
|
7517
|
+
* One panel's path for measured pool facts.
|
|
7518
|
+
*
|
|
7519
|
+
* Panel-keyed for the same reason the credential copies are region-keyed: the
|
|
7520
|
+
* panels are parallel stacks and must never overwrite each other's measurements.
|
|
7521
|
+
*/
|
|
7522
|
+
function traePoolStorePath(panel) {
|
|
7523
|
+
return join(resolveDshHome(), `${POOL_STORE_PREFIX}.${panel}.json`);
|
|
7524
|
+
}
|
|
7525
|
+
/**
|
|
7526
|
+
* The recovery time a stored record implies — declared, or recoverable from its
|
|
7527
|
+
* own body.
|
|
7528
|
+
*
|
|
7529
|
+
* The field itself wins when present: it is what the writer computed with the
|
|
7530
|
+
* full input set, and re-deriving it could only lose information.
|
|
7531
|
+
*
|
|
7532
|
+
* The fallback exists because a record can state a time in its `message` and
|
|
7533
|
+
* still have no `retryAtMs`. Read-time healing is the only place this can be
|
|
7534
|
+
* repaired for EXISTING records: every consumer receives probes through
|
|
7535
|
+
* {@link readTraePoolProbes}, so healing here fixes all of them at once. Re-
|
|
7536
|
+
* deriving at each consumer instead would be several copies of one rule, and the
|
|
7537
|
+
* next consumer added would silently miss it.
|
|
7538
|
+
*
|
|
7539
|
+
* Not a permanent second derivation: {@link writeTraePoolProbes} reads through
|
|
7540
|
+
* this function before it writes, so the very next write persists the healed
|
|
7541
|
+
* value.
|
|
7542
|
+
*
|
|
7543
|
+
* Scoped to the two outcomes whose vocabulary HAS a stated time. Widening it
|
|
7544
|
+
* would let an incidental sentence in an `ok` body attach a countdown to a
|
|
7545
|
+
* healthy account — the card prints any present `retryAtMs` beside the outcome,
|
|
7546
|
+
* so that would render a waiting time on an account waiting for nothing.
|
|
7547
|
+
*/
|
|
7548
|
+
function retryAtMsOf(probe) {
|
|
7549
|
+
if (probe.retryAtMs !== void 0) return probe.retryAtMs;
|
|
7550
|
+
if (probe.outcome !== "rate-limited" && probe.outcome !== "out-of-credit") return void 0;
|
|
7551
|
+
if (probe.message === void 0) return void 0;
|
|
7552
|
+
return parseUpstreamResetAt(probe.message);
|
|
7553
|
+
}
|
|
7554
|
+
/**
|
|
7555
|
+
* Read one panel's measured facts.
|
|
7556
|
+
*
|
|
7557
|
+
* Contains NO credentials. The one free-text field is `message`, and it is
|
|
7558
|
+
* REDACTED BY THE WRITER before it ever reaches this file, so a token-shaped
|
|
7559
|
+
* string cannot survive into storage.
|
|
7560
|
+
*
|
|
7561
|
+
* Returns an empty map for a missing, unreadable, malformed, or
|
|
7562
|
+
* version-mismatched file: all four mean "nothing measured yet", which is a valid
|
|
7563
|
+
* state the pool renders as "untested".
|
|
7564
|
+
*/
|
|
7565
|
+
async function readTraePoolProbes(panel) {
|
|
7566
|
+
let raw;
|
|
7567
|
+
try {
|
|
7568
|
+
raw = await readFile(traePoolStorePath(panel), "utf8");
|
|
7569
|
+
} catch {
|
|
7570
|
+
return {};
|
|
7571
|
+
}
|
|
7572
|
+
let parsed;
|
|
7573
|
+
try {
|
|
7574
|
+
parsed = JSON.parse(raw);
|
|
7575
|
+
} catch {
|
|
7576
|
+
return {};
|
|
7577
|
+
}
|
|
7578
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
7579
|
+
const document = parsed;
|
|
7580
|
+
if (document.version !== STORE_FORMAT_VERSION) return {};
|
|
7581
|
+
if (typeof document.probes !== "object" || document.probes === null) return {};
|
|
7582
|
+
const probes = {};
|
|
7583
|
+
for (const [accountId, probe] of Object.entries(document.probes)) {
|
|
7584
|
+
if (accountId === "" || !isProbe(probe)) continue;
|
|
7585
|
+
const retryAtMs = retryAtMsOf(probe);
|
|
7586
|
+
const source = knownProbeSource(probe.source);
|
|
7587
|
+
probes[accountId] = {
|
|
7588
|
+
outcome: probe.outcome,
|
|
7589
|
+
atMs: probe.atMs,
|
|
7590
|
+
...source === void 0 ? {} : { source },
|
|
7591
|
+
...retryAtMs === void 0 ? {} : { retryAtMs },
|
|
7592
|
+
...probe.message === void 0 ? {} : { message: probe.message },
|
|
7593
|
+
...probe.accountName === void 0 ? {} : { accountName: probe.accountName },
|
|
7594
|
+
...probe.modelId === void 0 ? {} : { modelId: probe.modelId }
|
|
7595
|
+
};
|
|
7596
|
+
}
|
|
7597
|
+
return probes;
|
|
7598
|
+
}
|
|
7599
|
+
/**
|
|
7600
|
+
* A stored source string reduced to a source this build understands.
|
|
7601
|
+
*
|
|
7602
|
+
* `isProbe` accepts any string so a newer build's label cannot destroy the
|
|
7603
|
+
* measurement it describes; this narrows it at the point of use. An unknown value
|
|
7604
|
+
* becomes `undefined`, which the card renders as "unknown" — the honest answer,
|
|
7605
|
+
* rather than mislabelling it as one of the two kinds we do know.
|
|
7606
|
+
*/
|
|
7607
|
+
function knownProbeSource(value) {
|
|
7608
|
+
return value === "test-batch" || value === "live-request" ? value : void 0;
|
|
7609
|
+
}
|
|
7610
|
+
/**
|
|
7611
|
+
* Merge ONE new measurement over the record already stored for that account.
|
|
7612
|
+
*
|
|
7613
|
+
* A plain `{ ...existing, ...update }` is wrong here, and the damage is silent.
|
|
7614
|
+
* The two writers describe the same account at different moments and carry
|
|
7615
|
+
* different fields:
|
|
7616
|
+
*
|
|
7617
|
+
* - `test-batch` measures the TARGET MODEL deliberately, so it is the writer
|
|
7618
|
+
* that can produce a reset point (`retryAtMs`) parsed out of the upstream's
|
|
7619
|
+
* own failure body.
|
|
7620
|
+
* - `live-request` reports what a real chat request just hit. It often carries
|
|
7621
|
+
* no `retryAtMs` at all (a transport blip names no time), and a spread would
|
|
7622
|
+
* therefore DELETE the reset point the probe had already resolved.
|
|
7623
|
+
*
|
|
7624
|
+
* Measured consequence of the spread (reference implementation, a real
|
|
7625
|
+
* quota-exhausted account): the probe recorded "quota refreshes 2026-11-01", the
|
|
7626
|
+
* next live failure rewrote the record without that field, and the pool then
|
|
7627
|
+
* reported "retry in about 30 minutes" — putting an exhausted account back into
|
|
7628
|
+
* rotation to be refused again. The correct value was in the file one write
|
|
7629
|
+
* earlier.
|
|
7630
|
+
*
|
|
7631
|
+
* So `retryAtMs` is INHERITED when the update does not state one, and every other
|
|
7632
|
+
* field is the update's (the newer measurement owns `outcome`, `atMs`, `source`
|
|
7633
|
+
* and `message`). Inheriting is only about the one field whose absence is not a
|
|
7634
|
+
* statement: an update that DOES carry a time still overrides, which is what lets
|
|
7635
|
+
* a fresh, shorter cooldown supersede a stale longer one.
|
|
7636
|
+
*
|
|
7637
|
+
* `accountName` is INHERITED for the same reason: its absence is not a statement.
|
|
7638
|
+
* A writer that could not see the account must not erase the name an earlier
|
|
7639
|
+
* measurement DID see — that name is the only thing that can identify a member
|
|
7640
|
+
* whose sign-in has gone.
|
|
7641
|
+
*
|
|
7642
|
+
* `modelId` is deliberately NOT inherited (see the field note in
|
|
7643
|
+
* `account-pool.ts`): it describes THIS measurement, not the account.
|
|
7644
|
+
*
|
|
7645
|
+
* Pure and exported so the rule is unit-testable without a filesystem — this is
|
|
7646
|
+
* exactly the kind of "two writers, one slot" rule that bites silently.
|
|
7647
|
+
*/
|
|
7648
|
+
function mergePoolProbe(existing, update) {
|
|
7649
|
+
if (existing === void 0) return update;
|
|
7650
|
+
const merged = update.accountName === void 0 && existing.accountName !== void 0 ? {
|
|
7651
|
+
...update,
|
|
7652
|
+
accountName: existing.accountName
|
|
7653
|
+
} : update;
|
|
7654
|
+
if (update.retryAtMs !== void 0) return merged;
|
|
7655
|
+
if (existing.retryAtMs === void 0 || existing.retryAtMs <= update.atMs) return merged;
|
|
7656
|
+
return {
|
|
7657
|
+
...merged,
|
|
7658
|
+
retryAtMs: existing.retryAtMs
|
|
7659
|
+
};
|
|
7660
|
+
}
|
|
7661
|
+
/**
|
|
7662
|
+
* Merge measurements into one panel's store and write it atomically.
|
|
7663
|
+
*
|
|
7664
|
+
* A MERGE rather than a replace, because batches measure one account at a time and
|
|
7665
|
+
* a partial failure must not erase what earlier runs learned. An account absent
|
|
7666
|
+
* from `updates` keeps its previous measurement, and an account present in both is
|
|
7667
|
+
* combined by {@link mergePoolProbe} rather than overwritten.
|
|
7668
|
+
*
|
|
7669
|
+
* Never throws: a failed write loses only the newest measurements, and the
|
|
7670
|
+
* caller's results are still returned to the user. Surfacing a disk error here
|
|
7671
|
+
* would fail an operation that actually succeeded.
|
|
7672
|
+
*/
|
|
7673
|
+
async function writeTraePoolProbes(panel, updates) {
|
|
7674
|
+
if (Object.keys(updates).length === 0) return;
|
|
7675
|
+
const path = traePoolStorePath(panel);
|
|
7676
|
+
try {
|
|
7677
|
+
await withFileLock(path, async () => {
|
|
7678
|
+
const existing = await readTraePoolProbes(panel);
|
|
7679
|
+
const merged = { ...existing };
|
|
7680
|
+
for (const [accountId, update] of Object.entries(updates)) merged[accountId] = mergePoolProbe(existing[accountId], update);
|
|
7681
|
+
await writeFileAtomic(path, `${JSON.stringify({
|
|
7682
|
+
version: STORE_FORMAT_VERSION,
|
|
7683
|
+
probes: merged
|
|
7684
|
+
}, null, 2)}\n`, {
|
|
7685
|
+
mode: 384,
|
|
7686
|
+
dirMode: 448
|
|
7687
|
+
});
|
|
7688
|
+
});
|
|
7689
|
+
} catch {}
|
|
7690
|
+
}
|
|
7691
|
+
/**
|
|
7692
|
+
* Forget one account's measurement, keeping every other row.
|
|
7693
|
+
*
|
|
7694
|
+
* Used when the user removes a pool member: its measurement is no longer about a
|
|
7695
|
+
* member, and leaving it in the file would resurrect a stale `rate-limited` row if
|
|
7696
|
+
* the account were added back later. Written through the same lock/atomic path so a
|
|
7697
|
+
* removal cannot interleave with a batch.
|
|
7698
|
+
*/
|
|
7699
|
+
async function dropTraePoolProbe(panel, accountId) {
|
|
7700
|
+
const existing = await readTraePoolProbes(panel);
|
|
7701
|
+
if (existing[accountId] === void 0) return;
|
|
7702
|
+
const path = traePoolStorePath(panel);
|
|
7703
|
+
const probes = {};
|
|
7704
|
+
for (const [id, probe] of Object.entries(existing)) if (id !== accountId) probes[id] = probe;
|
|
7705
|
+
try {
|
|
7706
|
+
await withFileLock(path, async () => {
|
|
7707
|
+
await writeFileAtomic(path, `${JSON.stringify({
|
|
7708
|
+
version: STORE_FORMAT_VERSION,
|
|
7709
|
+
probes
|
|
7710
|
+
}, null, 2)}\n`, {
|
|
7711
|
+
mode: 384,
|
|
7712
|
+
dirMode: 448
|
|
7713
|
+
});
|
|
7714
|
+
});
|
|
7715
|
+
} catch {}
|
|
7716
|
+
}
|
|
7717
|
+
//#endregion
|
|
6297
7718
|
//#region src/model-config.ts
|
|
6298
7719
|
/**
|
|
6299
7720
|
* Parse a secret-free CustomModel debug line emitted by Trae. Optional values
|
|
@@ -6615,7 +8036,13 @@ const regionStateConfig = z.object({
|
|
|
6615
8036
|
enabledModelIds: z.array(z.string()).default([]),
|
|
6616
8037
|
imageModelIds: z.array(z.string()).default([]),
|
|
6617
8038
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}),
|
|
6618
|
-
creditPosition: z.union([z.const("composer"), z.const("stats")]).default("composer").description("积分读数显示位置:\"composer\" = 输入框工具栏(默认),\"stats\" = 输入框下方(与「轮次 / 步数 / token」统计同一行)。装了别的插件后读数不显示时改成 stats。")
|
|
8039
|
+
creditPosition: z.union([z.const("composer"), z.const("stats")]).default("composer").description("积分读数显示位置:\"composer\" = 输入框工具栏(默认),\"stats\" = 输入框下方(与「轮次 / 步数 / token」统计同一行)。装了别的插件后读数不显示时改成 stats。"),
|
|
8040
|
+
pool: z.object({
|
|
8041
|
+
enabled: z.boolean().default(false).description("账号池:打开后本面板由池中可用账号轮流计费(关闭时按下面的绑定账号)"),
|
|
8042
|
+
targetModelId: z.string().default("").description("账号池测速使用的模型 id(留空 = 自动选择免费模型)"),
|
|
8043
|
+
memberAccountIds: z.array(z.string()).default([]).description("加入本面板账号池的账号 id(显式勾选,默认为空)"),
|
|
8044
|
+
probeInputTokens: z.number().default(0).description("账号池测试请求填充的 token 量(0 = 最小请求,不填充)")
|
|
8045
|
+
}).default({})
|
|
6619
8046
|
});
|
|
6620
8047
|
const accountSelectionConfig = z.object({
|
|
6621
8048
|
cn: z.string().description("Selected domestic (CN) account id (never a token)"),
|
|
@@ -6644,6 +8071,28 @@ const Config = z.object({
|
|
|
6644
8071
|
});
|
|
6645
8072
|
/** Every region, in card tab order. */
|
|
6646
8073
|
const REGION_KEYS = ["cn", "ai"];
|
|
8074
|
+
/**
|
|
8075
|
+
* What ONE failed real request means for the account that served it (issue #44).
|
|
8076
|
+
*
|
|
8077
|
+
* The shim only reports account-scoped kinds here, so this table never has to
|
|
8078
|
+
* interpret `not_found` (a model the account cannot see) or `client` (our own bad
|
|
8079
|
+
* body) — both would exclude a healthy account for good over something that is
|
|
8080
|
+
* not about the account at all.
|
|
8081
|
+
*
|
|
8082
|
+
* `credential-rejected` is the one permanent entry, and deliberately so: a 401
|
|
8083
|
+
* from the chat gateway means the stored token cannot be refreshed into one that
|
|
8084
|
+
* works, and the honest recovery is a re-login (which the card offers), not a
|
|
8085
|
+
* silent retry every half hour.
|
|
8086
|
+
*/
|
|
8087
|
+
const POOL_OUTCOME_BY_KIND = {
|
|
8088
|
+
authentication: "credential-rejected",
|
|
8089
|
+
hard_credit: "out-of-credit",
|
|
8090
|
+
soft_rate: "rate-limited",
|
|
8091
|
+
server: "unavailable",
|
|
8092
|
+
not_found: "not-found",
|
|
8093
|
+
client: "failed",
|
|
8094
|
+
unconfigured: "failed"
|
|
8095
|
+
};
|
|
6647
8096
|
function apply(ctx, config) {
|
|
6648
8097
|
const settingsNs = settingsNamespaceOf(ctx);
|
|
6649
8098
|
let current = () => config;
|
|
@@ -6854,7 +8303,8 @@ function apply(ctx, config) {
|
|
|
6854
8303
|
};
|
|
6855
8304
|
shimRoutes.push({
|
|
6856
8305
|
label: route,
|
|
6857
|
-
client: delegating
|
|
8306
|
+
client: delegating,
|
|
8307
|
+
pool: poolShimHooksOf(panel)
|
|
6858
8308
|
});
|
|
6859
8309
|
}
|
|
6860
8310
|
const shim = createTraeShim({
|
|
@@ -6922,11 +8372,13 @@ function apply(ctx, config) {
|
|
|
6922
8372
|
* A throwaway client is used on purpose: the shared `usageClient` reads
|
|
6923
8373
|
* `store.resolve()`, which is bound to the selected account, and calling
|
|
6924
8374
|
* `selectAccount` to look at a neighbour would both race concurrent chat
|
|
6925
|
-
* requests and persist a switch the user never made.
|
|
6926
|
-
*
|
|
8375
|
+
* requests and persist a switch the user never made. The credential is
|
|
8376
|
+
* resolved (not merely looked up) so an account whose short-lived access
|
|
8377
|
+
* token lapsed overnight still answers: its refresh token is good for
|
|
8378
|
+
* months, and reporting `—` for a recoverable account is a lie.
|
|
6927
8379
|
*/
|
|
6928
8380
|
creditsOfAccount: async (accountId) => {
|
|
6929
|
-
const credential = await store.
|
|
8381
|
+
const credential = await store.resolveCredentialOf(accountId);
|
|
6930
8382
|
if (credential === void 0) return void 0;
|
|
6931
8383
|
return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
|
|
6932
8384
|
},
|
|
@@ -6934,7 +8386,7 @@ function apply(ctx, config) {
|
|
|
6934
8386
|
const state = { id: accountId };
|
|
6935
8387
|
const selectedCredential = await store.current();
|
|
6936
8388
|
const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
|
|
6937
|
-
const credential = store.
|
|
8389
|
+
const credential = store.resolveCredentialOf(accountId);
|
|
6938
8390
|
return new TraeUsageClient({
|
|
6939
8391
|
credential: async () => credential,
|
|
6940
8392
|
deviceId: async () => (await identity()).deviceId
|
|
@@ -6952,7 +8404,7 @@ function apply(ctx, config) {
|
|
|
6952
8404
|
claimCheckinOf: async (accountId) => {
|
|
6953
8405
|
const selectedCredential = await store.current();
|
|
6954
8406
|
const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
|
|
6955
|
-
const credential = store.
|
|
8407
|
+
const credential = store.resolveCredentialOf(accountId);
|
|
6956
8408
|
return new TraeUsageClient({
|
|
6957
8409
|
credential: async () => credential,
|
|
6958
8410
|
deviceId: async () => (await identity()).deviceId
|
|
@@ -7012,10 +8464,389 @@ function apply(ctx, config) {
|
|
|
7012
8464
|
};
|
|
7013
8465
|
},
|
|
7014
8466
|
invalidateAdapter: () => {},
|
|
7015
|
-
rawDiagnostic: () => rawDiagnostic()
|
|
8467
|
+
rawDiagnostic: () => rawDiagnostic(),
|
|
8468
|
+
wireResolver,
|
|
8469
|
+
identity,
|
|
8470
|
+
routeOf: (provider) => routeOfTraeProvider(provider) ?? "solo"
|
|
8471
|
+
};
|
|
8472
|
+
}
|
|
8473
|
+
/** This panel's saved pool preferences, unwrapped and defaulted to OFF. */
|
|
8474
|
+
const poolPreferencesOf = (panel) => unwrapVolatileDeep(regionStateOf(current(), panel).pool) ?? {};
|
|
8475
|
+
/**
|
|
8476
|
+
* Project one account's card credits onto the pool's ranking shape.
|
|
8477
|
+
*
|
|
8478
|
+
* The pool's `total` is the figure a request can SPEND, which on Trae is the
|
|
8479
|
+
* General bucket alone (SOLO chat never drains Work). The card's `total` is the
|
|
8480
|
+
* wider `available` figure, so it is deliberately NOT reused here: ranking by it
|
|
8481
|
+
* would put an account with a large Work balance and no General credit in front
|
|
8482
|
+
* of one that can actually serve. `undefined` (the read failed) stays undefined
|
|
8483
|
+
* so `rankPool` treats it as zero rather than as full.
|
|
8484
|
+
*/
|
|
8485
|
+
/**
|
|
8486
|
+
* Credit totals the pool ranks on, cached per account.
|
|
8487
|
+
*
|
|
8488
|
+
* The CHAT path must not spend an upstream round-trip per candidate just to
|
|
8489
|
+
* decide who bills: {@link poolMembersOf} reads every balance, which is right
|
|
8490
|
+
* for the card (one read per poll) and far too slow per request. So the card's
|
|
8491
|
+
* poll warms this cache and the router only reads it.
|
|
8492
|
+
*
|
|
8493
|
+
* A cold cache means "unknown", not "no credit": `rankPool` orders a missing
|
|
8494
|
+
* figure as zero and then falls back to token freshness, so a cold start still
|
|
8495
|
+
* picks a member — the user chose the roster — instead of refusing to bill.
|
|
8496
|
+
*/
|
|
8497
|
+
const poolCreditCache = {
|
|
8498
|
+
cn: /* @__PURE__ */ new Map(),
|
|
8499
|
+
enterprise: /* @__PURE__ */ new Map(),
|
|
8500
|
+
ai: /* @__PURE__ */ new Map()
|
|
8501
|
+
};
|
|
8502
|
+
const poolCreditsOfAccount = async (panel, accountId) => {
|
|
8503
|
+
const credits = await stacks[panel].creditsOfAccount(accountId);
|
|
8504
|
+
if (credits === void 0) return void 0;
|
|
8505
|
+
const within3d = credits.expiry?.within3d;
|
|
8506
|
+
const projected = {
|
|
8507
|
+
total: credits.generalAvailable,
|
|
8508
|
+
generalAvailable: credits.generalAvailable,
|
|
8509
|
+
workAvailable: credits.workAvailable,
|
|
8510
|
+
expiringSoon: within3d?.total ?? 0,
|
|
8511
|
+
...within3d?.earliestExpiresAtMs === void 0 ? {} : { nearestExpiryMs: within3d.earliestExpiresAtMs }
|
|
8512
|
+
};
|
|
8513
|
+
poolCreditCache[panel].set(accountId, projected);
|
|
8514
|
+
return projected;
|
|
8515
|
+
};
|
|
8516
|
+
/**
|
|
8517
|
+
* Assemble one pool member per LOCAL account, in the store's order.
|
|
8518
|
+
*
|
|
8519
|
+
* "Member" means the account still has a sign-in this machine can use: a saved
|
|
8520
|
+
* id whose desktop credentials vanished is a ghost, and a ghost cannot be tested
|
|
8521
|
+
* or checked in, so listing it as a member would enable a batch over nothing.
|
|
8522
|
+
* The saved list is intersected separately (see
|
|
8523
|
+
* {@link TraePoolDeps.effectiveMemberAccountIds}) — here the store is the source.
|
|
8524
|
+
*
|
|
8525
|
+
* Balance reads are settled independently: one account whose credit read fails
|
|
8526
|
+
* must not take the whole pool document down, and it ranks as zero rather than
|
|
8527
|
+
* disappearing.
|
|
8528
|
+
*/
|
|
8529
|
+
const poolMembersOf = async (panel) => {
|
|
8530
|
+
const probes = await readTraePoolProbes(poolKeyOfPanel(panel));
|
|
8531
|
+
const choices = await stacks[panel].store.accounts();
|
|
8532
|
+
return await Promise.all(choices.map(async (choice) => {
|
|
8533
|
+
const credits = await poolCreditsOfAccount(panel, choice.id).catch(() => void 0);
|
|
8534
|
+
return {
|
|
8535
|
+
account: {
|
|
8536
|
+
id: choice.id,
|
|
8537
|
+
accountName: choice.accountName
|
|
8538
|
+
},
|
|
8539
|
+
...credits === void 0 ? {} : { credits },
|
|
8540
|
+
...probes[choice.id] === void 0 ? {} : { probe: probes[choice.id] },
|
|
8541
|
+
...choice.tokenExpiresAtMs === void 0 ? {} : { tokenExpiresAtMs: choice.tokenExpiresAtMs }
|
|
8542
|
+
};
|
|
8543
|
+
}));
|
|
8544
|
+
};
|
|
8545
|
+
/**
|
|
8546
|
+
* The catalog a pool test resolves its target model against.
|
|
8547
|
+
*
|
|
8548
|
+
* The LAST discovery wins, and the saved/live catalog is the fallback: a test
|
|
8549
|
+
* clicked before the first discovery lands must still find the free models the
|
|
8550
|
+
* region already knows, and one clicked after it must not be judged against a
|
|
8551
|
+
* roster the upstream has since replaced.
|
|
8552
|
+
*/
|
|
8553
|
+
const poolCatalogOf = (panel) => stacks[panel].lastDiscovery.length > 0 ? stacks[panel].lastDiscovery : stacks[panel].catalog.current();
|
|
8554
|
+
/** Id → display name for one panel, for the rows a batch reports. */
|
|
8555
|
+
const poolMemberNamesOf = async (panel) => {
|
|
8556
|
+
const names = {};
|
|
8557
|
+
for (const member of await poolMembersOf(panel)) names[member.account.id] = member.account.accountName;
|
|
8558
|
+
return names;
|
|
8559
|
+
};
|
|
8560
|
+
/**
|
|
8561
|
+
* The accounts a batch runs on: the SAVED member list, narrowed to what this
|
|
8562
|
+
* machine can still reach, in the user's own order.
|
|
8563
|
+
*
|
|
8564
|
+
* Order is the user's, not the ranking's, on purpose: a batch result table that
|
|
8565
|
+
* reorders itself between presses is hard to read row by row, and the ranking
|
|
8566
|
+
* is already shown by the table above it.
|
|
8567
|
+
*/
|
|
8568
|
+
const poolTargetsOf = async (panel) => {
|
|
8569
|
+
const saved = poolPreferencesOf(panel).memberAccountIds ?? [];
|
|
8570
|
+
const names = await poolMemberNamesOf(panel);
|
|
8571
|
+
return effectiveMembersOf(saved, new Set(Object.keys(names))).map((accountId) => ({
|
|
8572
|
+
accountId,
|
|
8573
|
+
accountName: names[accountId] ?? ""
|
|
8574
|
+
}));
|
|
8575
|
+
};
|
|
8576
|
+
/**
|
|
8577
|
+
* Pool members from LOCAL state only: the store's logins, the saved probes and
|
|
8578
|
+
* the cached credit totals. No upstream call, so it is safe on the request
|
|
8579
|
+
* path (see {@link poolCreditCache} for why the cache exists).
|
|
8580
|
+
*/
|
|
8581
|
+
async function localPoolMembers(panel) {
|
|
8582
|
+
const probes = await readTraePoolProbes(poolKeyOfPanel(panel)).catch(() => ({}));
|
|
8583
|
+
const choices = await stacks[panel].store.accounts().catch(() => []);
|
|
8584
|
+
const cache = poolCreditCache[panel];
|
|
8585
|
+
return choices.map((choice) => {
|
|
8586
|
+
const credits = cache.get(choice.id);
|
|
8587
|
+
return {
|
|
8588
|
+
account: {
|
|
8589
|
+
id: choice.id,
|
|
8590
|
+
accountName: choice.accountName
|
|
8591
|
+
},
|
|
8592
|
+
...credits === void 0 ? {} : { credits },
|
|
8593
|
+
...probes[choice.id] === void 0 ? {} : { probe: probes[choice.id] },
|
|
8594
|
+
...choice.tokenExpiresAtMs === void 0 ? {} : { tokenExpiresAtMs: choice.tokenExpiresAtMs }
|
|
8595
|
+
};
|
|
8596
|
+
});
|
|
8597
|
+
}
|
|
8598
|
+
/**
|
|
8599
|
+
* The members that may actually be billed: the user's SAVED opt-in list
|
|
8600
|
+
* intersected with what this machine can still reach, minus the accounts that
|
|
8601
|
+
* already failed this request (`billableMembers` is the one definition of that
|
|
8602
|
+
* rule, shared with the batch paths).
|
|
8603
|
+
*/
|
|
8604
|
+
async function poolBillableMembers(panel, tried = []) {
|
|
8605
|
+
return billableMembers(await localPoolMembers(panel), poolPreferencesOf(panel).memberAccountIds ?? [], tried);
|
|
8606
|
+
}
|
|
8607
|
+
/**
|
|
8608
|
+
* Point the NEXT request at the best pooled account, or back at the bound one
|
|
8609
|
+
* when the pool is off (issue #44).
|
|
8610
|
+
*
|
|
8611
|
+
* Returns the account it chose. Both the chat path and the card's usage read go
|
|
8612
|
+
* through here, so "currently serving" on screen and the next real request
|
|
8613
|
+
* cannot disagree — one rule, one answer.
|
|
8614
|
+
*
|
|
8615
|
+
* When EVERY member is excluded the rotation is cleared rather than pointed at
|
|
8616
|
+
* an excluded account: the bound account then serves, which is the user's own
|
|
8617
|
+
* explicit choice and the only account a refusal would not have served anyway.
|
|
8618
|
+
* The card reports the exclusion reasons, so this is never silent.
|
|
8619
|
+
*/
|
|
8620
|
+
async function applyPoolSelection(panel) {
|
|
8621
|
+
const store = stacks[panel].store;
|
|
8622
|
+
if (poolPreferencesOf(panel).enabled !== true) {
|
|
8623
|
+
store.setRotatedAccount(void 0);
|
|
8624
|
+
return;
|
|
8625
|
+
}
|
|
8626
|
+
try {
|
|
8627
|
+
const winner = pickAccount(await poolBillableMembers(panel), Date.now());
|
|
8628
|
+
store.setRotatedAccount(winner?.id);
|
|
8629
|
+
return winner?.id;
|
|
8630
|
+
} catch {
|
|
8631
|
+
store.setRotatedAccount(void 0);
|
|
8632
|
+
return;
|
|
8633
|
+
}
|
|
8634
|
+
}
|
|
8635
|
+
/**
|
|
8636
|
+
* The client for the next account worth trying, bound to THAT account.
|
|
8637
|
+
*
|
|
8638
|
+
* Built per attempt from the account's own stored credential, so a failover
|
|
8639
|
+
* never rebinds the store and never disturbs a request that is already billing
|
|
8640
|
+
* the chosen account (the same rule per-account probes follow, issue #25). The
|
|
8641
|
+
* bridge is the same one a normal turn uses, so display ids still resolve to
|
|
8642
|
+
* their wire names.
|
|
8643
|
+
*/
|
|
8644
|
+
async function failoverClientFor(panel, tried) {
|
|
8645
|
+
const next = pickAccount(await poolBillableMembers(panel, tried), Date.now());
|
|
8646
|
+
if (next === void 0) return void 0;
|
|
8647
|
+
const stack = stacks[panel];
|
|
8648
|
+
const accountId = next.id;
|
|
8649
|
+
return {
|
|
8650
|
+
accountId,
|
|
8651
|
+
client: new TraeSoloBridge(new TraeSoloUpstreamClient({
|
|
8652
|
+
credential: async () => {
|
|
8653
|
+
const credential = await stack.store.resolveCredentialOf(accountId);
|
|
8654
|
+
if (credential === void 0) throw new Error(`no stored credential for account ${accountId}`);
|
|
8655
|
+
return credential;
|
|
8656
|
+
},
|
|
8657
|
+
identity: () => stack.identity(),
|
|
8658
|
+
route: stack.routeOf(providerOfPanel(panel)),
|
|
8659
|
+
log: (message, detail) => ctx.logger.warn(message, detail)
|
|
8660
|
+
}), stack.catalog, stack.wireResolver)
|
|
8661
|
+
};
|
|
8662
|
+
}
|
|
8663
|
+
/**
|
|
8664
|
+
* Persist what a real request taught us about an account (issue #44).
|
|
8665
|
+
*
|
|
8666
|
+
* Written as a `live-request` probe so the card can tell a measurement taken by
|
|
8667
|
+
* a batch from one taken by traffic, and so the ranking excludes the account on
|
|
8668
|
+
* its next turn instead of walking back into the same limit.
|
|
8669
|
+
*/
|
|
8670
|
+
async function recordPoolFailure(panel, accountId, kind, message) {
|
|
8671
|
+
const outcome = POOL_OUTCOME_BY_KIND[kind];
|
|
8672
|
+
if (outcome === void 0) return;
|
|
8673
|
+
await writeTraePoolProbes(poolKeyOfPanel(panel), { [accountId]: {
|
|
8674
|
+
outcome,
|
|
8675
|
+
atMs: Date.now(),
|
|
8676
|
+
source: "live-request",
|
|
8677
|
+
message: `${kind}: ${message}`
|
|
8678
|
+
} }).catch(() => {});
|
|
8679
|
+
}
|
|
8680
|
+
/**
|
|
8681
|
+
* Why nothing else could serve, as one clause for the error the caller sees.
|
|
8682
|
+
*
|
|
8683
|
+
* `undefined` when the pool is off (the plain upstream error is the whole
|
|
8684
|
+
* story) or when there was in fact a candidate left — the shim asked because it
|
|
8685
|
+
* ran out of attempts, not out of accounts.
|
|
8686
|
+
*/
|
|
8687
|
+
async function poolShortfallOf(panel, tried) {
|
|
8688
|
+
if (poolPreferencesOf(panel).enabled !== true) return void 0;
|
|
8689
|
+
let members;
|
|
8690
|
+
let remaining;
|
|
8691
|
+
try {
|
|
8692
|
+
members = await poolBillableMembers(panel);
|
|
8693
|
+
if (members.length === 0) return "the account pool is enabled but has no usable member";
|
|
8694
|
+
remaining = await poolBillableMembers(panel, tried);
|
|
8695
|
+
} catch {
|
|
8696
|
+
return;
|
|
8697
|
+
}
|
|
8698
|
+
if (remaining.length === 0) return `all ${members.length} pooled account(s) were tried for this request`;
|
|
8699
|
+
const ranked = rankPool(remaining, Date.now());
|
|
8700
|
+
const reasons = [...new Set(ranked.flatMap((entry) => entry.excludedBy === void 0 ? [] : [entry.excludedBy]))];
|
|
8701
|
+
return reasons.length === 0 ? void 0 : `every remaining pooled account is excluded (${reasons.join(", ")})`;
|
|
8702
|
+
}
|
|
8703
|
+
/**
|
|
8704
|
+
* The shim's view of one panel's pool.
|
|
8705
|
+
*
|
|
8706
|
+
* Declared as a hoisted function because the shim's route table is built ABOVE
|
|
8707
|
+
* this point in `apply`; only the bodies run later, on a real request.
|
|
8708
|
+
*/
|
|
8709
|
+
function poolShimHooksOf(panel) {
|
|
8710
|
+
return {
|
|
8711
|
+
prepare: () => applyPoolSelection(panel),
|
|
8712
|
+
next: (tried) => failoverClientFor(panel, tried),
|
|
8713
|
+
failed: (accountId, kind, message) => recordPoolFailure(panel, accountId, kind, message),
|
|
8714
|
+
shortfall: (tried) => poolShortfallOf(panel, tried)
|
|
7016
8715
|
};
|
|
7017
8716
|
}
|
|
7018
8717
|
/**
|
|
8718
|
+
* The pool's dependency bag for ONE panel.
|
|
8719
|
+
*
|
|
8720
|
+
* Every member re-reads the store on each call rather than capturing a list:
|
|
8721
|
+
* a batch runs for minutes, and a sign-in the user performed half-way through
|
|
8722
|
+
* must be visible to the rows it has not reached yet.
|
|
8723
|
+
*
|
|
8724
|
+
* The bag is built per REQUEST, so it captures the panel and never has to be
|
|
8725
|
+
* told which tab asked.
|
|
8726
|
+
*/
|
|
8727
|
+
const poolDepsOf = (panel) => {
|
|
8728
|
+
const bridges = /* @__PURE__ */ new Map();
|
|
8729
|
+
const chatBridgeFor = (accountId) => {
|
|
8730
|
+
const existing = bridges.get(accountId);
|
|
8731
|
+
if (existing !== void 0) return existing;
|
|
8732
|
+
const stack = stacks[panel];
|
|
8733
|
+
const bridge = new TraeSoloBridge(new TraeSoloUpstreamClient({
|
|
8734
|
+
credential: async () => {
|
|
8735
|
+
const credential = await stack.store.resolveCredentialOf(accountId);
|
|
8736
|
+
if (credential === void 0) throw new Error(`no stored credential for account ${accountId}`);
|
|
8737
|
+
return credential;
|
|
8738
|
+
},
|
|
8739
|
+
identity: () => stack.identity(),
|
|
8740
|
+
route: stack.routeOf(providerOfPanel(panel)),
|
|
8741
|
+
log: (message, detail) => ctx.logger.warn(message, detail)
|
|
8742
|
+
}), stack.catalog, stack.wireResolver);
|
|
8743
|
+
const chat = (body, signal) => bridge.chatStream(body, signal);
|
|
8744
|
+
bridges.set(accountId, chat);
|
|
8745
|
+
return chat;
|
|
8746
|
+
};
|
|
8747
|
+
const probeDeps = () => ({
|
|
8748
|
+
credentialFor: (accountId) => stacks[panel].store.resolveCredentialOf(accountId),
|
|
8749
|
+
checkinStatusOf: async (credential) => {
|
|
8750
|
+
return new TraeUsageClient({
|
|
8751
|
+
credential: async () => credential,
|
|
8752
|
+
deviceId: async () => (await stacks[panel].identity()).deviceId
|
|
8753
|
+
}).checkinStatus();
|
|
8754
|
+
},
|
|
8755
|
+
claimCheckinOf: async (credential) => {
|
|
8756
|
+
return new TraeUsageClient({
|
|
8757
|
+
credential: async () => credential,
|
|
8758
|
+
deviceId: async () => (await stacks[panel].identity()).deviceId
|
|
8759
|
+
}).claimCheckin();
|
|
8760
|
+
},
|
|
8761
|
+
chatFor: (credential) => chatBridgeFor(traeAccountId(credential))
|
|
8762
|
+
});
|
|
8763
|
+
/** How much text a probe should carry for this panel (0 = the minimal probe). */
|
|
8764
|
+
const probeInputTokensOf = (stored) => {
|
|
8765
|
+
const tokens = poolPreferencesOf(stored).probeInputTokens;
|
|
8766
|
+
return typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 ? Math.min(tokens, MAX_PROBE_INPUT_TOKENS) : 0;
|
|
8767
|
+
};
|
|
8768
|
+
/** One account's row, through the same bridge and probe the batch uses. */
|
|
8769
|
+
const testWith = async (target, modelId, probeInputTokens) => {
|
|
8770
|
+
const [row] = await testAllAccounts([target], modelId, (chatFor, credential, id) => {
|
|
8771
|
+
if (credential === void 0) return Promise.resolve({
|
|
8772
|
+
id,
|
|
8773
|
+
verdict: "auth",
|
|
8774
|
+
detail: "no stored credential for this account"
|
|
8775
|
+
});
|
|
8776
|
+
return probeOneModel(chatFor(credential), id, void 0, probeInputTokens);
|
|
8777
|
+
}, probeDeps());
|
|
8778
|
+
return row;
|
|
8779
|
+
};
|
|
8780
|
+
return {
|
|
8781
|
+
preferences: (stored) => poolPreferencesOf(stored),
|
|
8782
|
+
members: (stored) => poolMembersOf(stored),
|
|
8783
|
+
effectiveMemberAccountIds: async (stored) => {
|
|
8784
|
+
return effectiveMembersOf(poolPreferencesOf(stored).memberAccountIds ?? [], new Set((await poolMembersOf(stored)).map((member) => member.account.id)));
|
|
8785
|
+
},
|
|
8786
|
+
memberNames: async (stored) => {
|
|
8787
|
+
const names = {};
|
|
8788
|
+
for (const member of await poolMembersOf(stored)) names[member.account.id] = member.account.accountName;
|
|
8789
|
+
return names;
|
|
8790
|
+
},
|
|
8791
|
+
otherAccounts: async (stored) => (await poolMembersOf(stored)).map((member) => member.account),
|
|
8792
|
+
currentAccountId: async (stored) => {
|
|
8793
|
+
const configured = configuredAccountsOf(current())[stored];
|
|
8794
|
+
return isTraeAccountId(configured) ? configured : void 0;
|
|
8795
|
+
},
|
|
8796
|
+
catalog: (stored) => poolCatalogOf(stored),
|
|
8797
|
+
checkinSupported: (stored) => regionOfPanel(stored) === "cn",
|
|
8798
|
+
checkin: async (stored) => checkinAllAccounts(await poolTargetsOf(stored), probeDeps()),
|
|
8799
|
+
checkinState: async (stored) => {
|
|
8800
|
+
const state = {};
|
|
8801
|
+
for (const target of await poolTargetsOf(stored)) {
|
|
8802
|
+
const credential = await stacks[stored].store.resolveCredentialOf(target.accountId);
|
|
8803
|
+
if (credential === void 0) continue;
|
|
8804
|
+
const client = new TraeUsageClient({
|
|
8805
|
+
credential: async () => credential,
|
|
8806
|
+
deviceId: async () => (await stacks[stored].identity()).deviceId
|
|
8807
|
+
});
|
|
8808
|
+
try {
|
|
8809
|
+
const status = await client.checkinStatus();
|
|
8810
|
+
state[target.accountId] = {
|
|
8811
|
+
checkedIn: status.checkedIn,
|
|
8812
|
+
deviceCheckedIn: status.didCheckedIn,
|
|
8813
|
+
enabled: status.enabled
|
|
8814
|
+
};
|
|
8815
|
+
} catch {}
|
|
8816
|
+
}
|
|
8817
|
+
return state;
|
|
8818
|
+
},
|
|
8819
|
+
test: async (stored, modelId, onRow) => {
|
|
8820
|
+
const probeInputTokens = probeInputTokensOf(stored);
|
|
8821
|
+
const rows = await testAllAccounts(await poolTargetsOf(stored), modelId, (chatFor, credential, id) => {
|
|
8822
|
+
if (credential === void 0) return Promise.resolve({
|
|
8823
|
+
id,
|
|
8824
|
+
verdict: "auth",
|
|
8825
|
+
detail: "no stored credential for this account"
|
|
8826
|
+
});
|
|
8827
|
+
return probeOneModel(chatFor(credential), id, void 0, probeInputTokens);
|
|
8828
|
+
}, probeDeps(), onRow);
|
|
8829
|
+
await writeTraePoolProbes(poolKeyOfPanel(stored), probeUpdatesOf(rows));
|
|
8830
|
+
return rows;
|
|
8831
|
+
},
|
|
8832
|
+
testOne: async (stored, accountId) => {
|
|
8833
|
+
const model = resolveTargetModel(poolCatalogOf(stored), poolPreferencesOf(stored).targetModelId);
|
|
8834
|
+
if (model.modelId === void 0) return void 0;
|
|
8835
|
+
const names = await poolMemberNamesOf(stored);
|
|
8836
|
+
const row = await testWith({
|
|
8837
|
+
accountId,
|
|
8838
|
+
accountName: names[accountId] ?? ""
|
|
8839
|
+
}, model.modelId, probeInputTokensOf(stored));
|
|
8840
|
+
await writeTraePoolProbes(poolKeyOfPanel(stored), probeUpdatesOf([row]));
|
|
8841
|
+
return row;
|
|
8842
|
+
},
|
|
8843
|
+
removeOne: async (stored, accountId) => {
|
|
8844
|
+
await dropTraePoolProbe(poolKeyOfPanel(stored), accountId);
|
|
8845
|
+
return true;
|
|
8846
|
+
}
|
|
8847
|
+
};
|
|
8848
|
+
};
|
|
8849
|
+
/**
|
|
7019
8850
|
* The app's current sign-in when it differs from the bound account (issue
|
|
7020
8851
|
* #29). Pure read: the store resolves the bound account verbatim, so billing
|
|
7021
8852
|
* never moves from here — the card's hint and the follow switch below are
|
|
@@ -7071,7 +8902,28 @@ function apply(ctx, config) {
|
|
|
7071
8902
|
creditsOfAccount: (panel, accountId) => stacks[panel].creditsOfAccount(accountId),
|
|
7072
8903
|
accountStateOf: (panel, accountId) => stacks[panel].accountStateOf(accountId),
|
|
7073
8904
|
claimCheckinOf: (panel, accountId) => stacks[panel].claimCheckinOf(accountId),
|
|
7074
|
-
liveLogin: (panel) => liveLoginOf(panel)
|
|
8905
|
+
liveLogin: (panel) => liveLoginOf(panel),
|
|
8906
|
+
pool: {
|
|
8907
|
+
preferences: (panel) => poolPreferencesOf(panel),
|
|
8908
|
+
members: (panel) => poolMembersOf(panel),
|
|
8909
|
+
effectiveMemberAccountIds: async (panel) => {
|
|
8910
|
+
return effectiveMembersOf(poolPreferencesOf(panel).memberAccountIds ?? [], new Set((await poolMembersOf(panel)).map((member) => member.account.id)));
|
|
8911
|
+
},
|
|
8912
|
+
memberNames: (panel) => poolMemberNamesOf(panel),
|
|
8913
|
+
otherAccounts: async (panel) => (await poolMembersOf(panel)).map((member) => member.account),
|
|
8914
|
+
currentAccountId: async (panel) => {
|
|
8915
|
+
await applyPoolSelection(panel).catch(() => void 0);
|
|
8916
|
+
const credential = await stacks[panel].store.current().catch(() => void 0);
|
|
8917
|
+
return credential === void 0 ? void 0 : traeAccountId(credential);
|
|
8918
|
+
},
|
|
8919
|
+
catalog: (panel) => poolCatalogOf(panel),
|
|
8920
|
+
checkinSupported: (panel) => regionOfPanel(panel) === "cn",
|
|
8921
|
+
checkin: (panel) => poolDepsOf(panel).checkin?.(panel) ?? Promise.resolve([]),
|
|
8922
|
+
checkinState: (panel) => poolDepsOf(panel).checkinState?.(panel) ?? Promise.resolve({}),
|
|
8923
|
+
test: (panel, modelId, onRow) => poolDepsOf(panel).test?.(panel, modelId, onRow) ?? Promise.resolve([]),
|
|
8924
|
+
testOne: (panel, accountId) => poolDepsOf(panel).testOne?.(panel, accountId) ?? Promise.resolve(void 0),
|
|
8925
|
+
removeOne: (panel, accountId) => poolDepsOf(panel).removeOne?.(panel, accountId) ?? Promise.resolve(false)
|
|
8926
|
+
}
|
|
7075
8927
|
}));
|
|
7076
8928
|
/**
|
|
7077
8929
|
* Live registration handles per region, filled once the shim is listening.
|
|
@@ -7306,4 +9158,4 @@ function apply(ctx, config) {
|
|
|
7306
9158
|
});
|
|
7307
9159
|
}
|
|
7308
9160
|
//#endregion
|
|
7309
|
-
export { Config, FALLBACK_TRAE_MODELS, FALLBACK_TRAE_MODELS_AI, REGION_GATEWAYS, SseDecoder, TRAE_ACCOUNT_STATUS_PATH, TRAE_AI_PROVIDER, TRAE_CHECKIN_PATH, TRAE_CN_AGENT_TASK_PATH, TRAE_CN_TITLE_PATH, TRAE_ENTERPRISE_PROVIDER, TRAE_MODELS_TEST_PATH, TRAE_MODEL_DETAIL_FUNCTIONS, TRAE_MODEL_DETAIL_PATH, TRAE_PANELS, TRAE_PAY_BASE, TRAE_PROBE_TIMEOUT_MS, TRAE_PROVIDER, TRAE_PROVIDERS, TRAE_PROVIDER_DISPLAY_NAMES, TRAE_PROVIDER_IDS, TRAE_PROVIDER_ID_DISPLAY_NAMES, TRAE_PROVIDER_ROUTES, TRAE_RAW_CHAT_V1_PATH, TRAE_RAW_CHAT_V2_PATH, TRAE_REASONING_EFFORTS, TRAE_REGIONS, TRAE_REGION_PARAM, TRAE_SETTINGS_NS, TRAE_SOLO_CHAT_PATH, TRAE_SOLO_FUNCTION, TRAE_SOLO_MODELS_PATH, TRAE_SOLO_REMOTE_BASE, TRAE_STATE_DB_FILENAME, TRAE_STREAM_IDLE_TIMEOUT_MS, TRAE_USAGE_PATH, TRAE_VERSION_CODE_FALLBACK, TraeCatalog, TraeCredentialStore, TraeDelegatingUpstreamClient, TraeFallbackUpstreamClient, TraeGatedUpstreamClient, TraeRawCapabilityController, TraeRawCapabilityState, TraeRawChatUpstreamClient, TraeSoloBridge, TraeSoloRemoteCatalogClient, TraeSoloUpstreamClient, TraeUsageClient, UnconfiguredTraeUpstreamClient, adaptLegacyPiAiContext, adaptModernPiAiContext, apply, applyContextBudgets, applyImageSelection, applyReasoningEffort, asVolatile, bridgeTraeSoloStream, buildTraeAgentTaskBody, buildTraeCnHeaders, buildTraeFusionRawChatEnvelope, buildTraeModelDetailRequest, buildTraeRawChatDraft, buildTraeRawChatRuntimeConfig, classifyProbeError, classifyProbeResult, classifyTraeRawChatFailure, configuredAccountsOf, createTraeAdapter, createTraeRawGateway, createTraeShim, creditPosition, decidePiAiGeneration, decodeRawChatChunk, decodeTraeEvent, decryptTraeStorageValue, deriveCatalog, describeIdShape, describeNameShape, describeTraeFailure, discoveredCatalog, displayNameOfTraeProvider, fallbackModelsFor, followAppLoginEnabled, generationOf, hashTraeRawChatArg, identityHeaders, inject, isEnterpriseAccount, legacyTraeOwnAuthPath, maskUserPath, mergeTraeModelSources, name, nextRegionCreditPosition, nextRegionEnabled, nextRegionSlots, normalizeTraeCredential, normalizeTraeVersionCode, panelOfKey, panelOfProvider, parseObservedModelConfig, parseReasoningCapability, parseTraeAuthValue, parseTraeCachedModel, parseTraeModelExtraConfigLogLine, parseTraeRawChatBehaviorConfig, parseTraeRemoteModel, parseTraeStorageDocument, piAiRuntimeInfo, pickTraeStorageIdentity, prepareSoloBody, probeBody, probeModelsSequentially, probeOneModel, probeTraeRawChatCapability, providerOfPanel, rawCapabilityDiagnostic, rawCapabilityFingerprint, readTraeCachedModel, readTraeCliIdentity, readTraeIdentity, refreshTraeCredential, regionCreditPositionOf, regionEnabled, regionEnabledOf, regionOfCredential, regionOfEdition, regionOfHost, regionOfPanel, regionOfTraeProvider, regionOfTraeStatusUrl, regionOfUserRegion, regionStateOf, registerTraeUsageRoute, resolveTraeIdentity, resolveTraeRawRuntime, routeOfTraeProvider, sanitizeCatalog, settingsNamespaceOf, traeEndpoint, traeInputModalities, traeModelDisplayName, traeOwnAuthPath, traeRawChatExtraInfo, traeStateDatabaseCandidates, traeStorageCandidates, traeWebUsage, traeWindowsAppNames, unwrapVolatile, unwrapVolatileDeep, withContextAdaptation, withTraeRegion };
|
|
9161
|
+
export { Config, FALLBACK_TRAE_MODELS, FALLBACK_TRAE_MODELS_AI, MAX_PROBE_INPUT_TOKENS, REGION_GATEWAYS, SseDecoder, TRAE_ACCOUNT_STATUS_PATH, TRAE_AI_PROVIDER, TRAE_CHECKIN_PATH, TRAE_CN_AGENT_TASK_PATH, TRAE_CN_TITLE_PATH, TRAE_ENTERPRISE_PROVIDER, TRAE_MODELS_TEST_PATH, TRAE_MODEL_DETAIL_FUNCTIONS, TRAE_MODEL_DETAIL_PATH, TRAE_PANELS, TRAE_PAY_BASE, TRAE_PROBE_TIMEOUT_MS, TRAE_PROVIDER, TRAE_PROVIDERS, TRAE_PROVIDER_DISPLAY_NAMES, TRAE_PROVIDER_IDS, TRAE_PROVIDER_ID_DISPLAY_NAMES, TRAE_PROVIDER_ROUTES, TRAE_RAW_CHAT_V1_PATH, TRAE_RAW_CHAT_V2_PATH, TRAE_REASONING_EFFORTS, TRAE_REGIONS, TRAE_REGION_PARAM, TRAE_SETTINGS_NS, TRAE_SOLO_CHAT_PATH, TRAE_SOLO_FUNCTION, TRAE_SOLO_MODELS_PATH, TRAE_SOLO_REMOTE_BASE, TRAE_STATE_DB_FILENAME, TRAE_STREAM_IDLE_TIMEOUT_MS, TRAE_USAGE_PATH, TRAE_VERSION_CODE_FALLBACK, TraeCatalog, TraeCredentialStore, TraeDelegatingUpstreamClient, TraeFallbackUpstreamClient, TraeGatedUpstreamClient, TraeRawCapabilityController, TraeRawCapabilityState, TraeRawChatUpstreamClient, TraeSoloBridge, TraeSoloRemoteCatalogClient, TraeSoloUpstreamClient, TraeUsageClient, UnconfiguredTraeUpstreamClient, adaptLegacyPiAiContext, adaptModernPiAiContext, apply, applyContextBudgets, applyImageSelection, applyReasoningEffort, asVolatile, bridgeTraeSoloStream, buildTraeAgentTaskBody, buildTraeCnHeaders, buildTraeFusionRawChatEnvelope, buildTraeModelDetailRequest, buildTraeRawChatDraft, buildTraeRawChatRuntimeConfig, classifyProbeError, classifyProbeResult, classifyTraeRawChatFailure, configuredAccountsOf, createTraeAdapter, createTraeRawGateway, createTraeShim, creditPosition, decidePiAiGeneration, decodeRawChatChunk, decodeTraeEvent, decryptTraeStorageValue, deriveCatalog, describeIdShape, describeNameShape, describeTraeFailure, discoveredCatalog, displayNameOfTraeProvider, fallbackModelsFor, followAppLoginEnabled, generationOf, hashTraeRawChatArg, identityHeaders, inject, isEnterpriseAccount, legacyTraeOwnAuthPath, maskUserPath, mergeTraeModelSources, name, nextRegionCreditPosition, nextRegionEnabled, nextRegionSlots, normalizeTraeCredential, normalizeTraeVersionCode, panelOfKey, panelOfProvider, parseObservedModelConfig, parseReasoningCapability, parseTraeAuthValue, parseTraeCachedModel, parseTraeModelExtraConfigLogLine, parseTraeRawChatBehaviorConfig, parseTraeRemoteModel, parseTraeStorageDocument, piAiRuntimeInfo, pickTraeStorageIdentity, prepareSoloBody, probeBody, probeModelsSequentially, probeOneModel, probeTraeRawChatCapability, providerOfPanel, rawCapabilityDiagnostic, rawCapabilityFingerprint, readTraeCachedModel, readTraeCliIdentity, readTraeIdentity, refreshTraeCredential, regionCreditPositionOf, regionEnabled, regionEnabledOf, regionOfCredential, regionOfEdition, regionOfHost, regionOfPanel, regionOfTraeProvider, regionOfTraeStatusUrl, regionOfUserRegion, regionStateOf, registerTraeUsageRoute, resolveTraeIdentity, resolveTraeRawRuntime, routeOfTraeProvider, sanitizeCatalog, settingsNamespaceOf, traeEndpoint, traeInputModalities, traeModelDisplayName, traeOwnAuthPath, traeRawChatExtraInfo, traeStateDatabaseCandidates, traeStorageCandidates, traeWebUsage, traeWindowsAppNames, unwrapVolatile, unwrapVolatileDeep, withContextAdaptation, withTraeRegion };
|