dsh-connect-trae 3.7.0 → 3.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +261 -0
- package/README.en.md +17 -11
- package/README.md +26 -16
- package/lib/client.js +1992 -154
- package/lib/index.d.ts +1589 -716
- package/lib/index.js +2676 -307
- package/package.json +16 -15
package/lib/index.js
CHANGED
|
@@ -129,9 +129,16 @@ function traeInputModalities(model) {
|
|
|
129
129
|
* (wire resolution, callable-key filtering) must keep matching the
|
|
130
130
|
* undecorated name; only the model rows handed to DSH (adapter catalog and
|
|
131
131
|
* model discovery) use this decorated name.
|
|
132
|
+
*
|
|
133
|
+
* `withRate` is false for the ENTERPRISE panel. The multiplier is a
|
|
134
|
+
* consumer-pool figure — personal credits per token, read from the consumer
|
|
135
|
+
* directory — and the tenant pool does not bill against it, so printing it in
|
|
136
|
+
* the model picker would price a model the tenant is not charged for. The rate
|
|
137
|
+
* is part of the NAME that reaches DSH, which is why this is a parameter here
|
|
138
|
+
* and not a rendering decision downstream.
|
|
132
139
|
*/
|
|
133
|
-
function traeModelDisplayName(model) {
|
|
134
|
-
return model.creditMultiplier === void 0 ? model.name : `${model.name} · x${model.creditMultiplier.toFixed(2)}`;
|
|
140
|
+
function traeModelDisplayName(model, withRate = true) {
|
|
141
|
+
return !withRate || model.creditMultiplier === void 0 ? model.name : `${model.name} · x${model.creditMultiplier.toFixed(2)}`;
|
|
135
142
|
}
|
|
136
143
|
/**
|
|
137
144
|
* Whether one model should be offered image input.
|
|
@@ -499,24 +506,61 @@ function readManifest(directory) {
|
|
|
499
506
|
/** Provider route this bundle owns for the domestic (CN) gateway. */
|
|
500
507
|
const TRAE_PROVIDER = "trae";
|
|
501
508
|
/**
|
|
509
|
+
* Provider route this bundle owns for the CN **enterprise (tenant)** gateway.
|
|
510
|
+
*
|
|
511
|
+
* A separate provider id, not a mode switch, because the two pools bill
|
|
512
|
+
* differently: a tenant account (`tob_online`) is served by BOTH gateways with
|
|
513
|
+
* the SAME credential, and only the enterprise one draws on the enterprise
|
|
514
|
+
* pool. Exposing one id would force a per-call guess about who pays.
|
|
515
|
+
*
|
|
516
|
+
* It never falls back to the consumer pool: picking this provider is the user
|
|
517
|
+
* saying "bill the tenant", so a refusal is reported as an error instead of
|
|
518
|
+
* being silently re-run against the personal quota.
|
|
519
|
+
*/
|
|
520
|
+
const TRAE_ENTERPRISE_PROVIDER = "trae-enterprise";
|
|
521
|
+
/**
|
|
502
522
|
* Provider route this bundle owns for the international (trae.ai) gateway.
|
|
503
523
|
* Named `-global` to match the plugin family's convention (`workbuddy-global`);
|
|
504
524
|
* the internal region bucket stays `ai`, which is the gateway/protocol name.
|
|
505
525
|
*/
|
|
506
526
|
const TRAE_AI_PROVIDER = "trae-global";
|
|
507
|
-
/** The provider
|
|
527
|
+
/** The provider ids one region publishes, in model-picker order. */
|
|
528
|
+
const TRAE_PROVIDER_IDS = {
|
|
529
|
+
cn: [TRAE_PROVIDER, TRAE_ENTERPRISE_PROVIDER],
|
|
530
|
+
ai: [TRAE_AI_PROVIDER]
|
|
531
|
+
};
|
|
532
|
+
/** Each region's default (first) provider id. */
|
|
508
533
|
const TRAE_PROVIDERS = {
|
|
509
534
|
cn: TRAE_PROVIDER,
|
|
510
535
|
ai: TRAE_AI_PROVIDER
|
|
511
536
|
};
|
|
537
|
+
const TRAE_PROVIDER_ROUTES = {
|
|
538
|
+
[TRAE_PROVIDER]: "solo",
|
|
539
|
+
[TRAE_ENTERPRISE_PROVIDER]: "enterprise",
|
|
540
|
+
[TRAE_AI_PROVIDER]: "solo"
|
|
541
|
+
};
|
|
542
|
+
/** Chat pool a provider id bills against; `undefined` for an unknown id. */
|
|
543
|
+
function routeOfTraeProvider(provider) {
|
|
544
|
+
return TRAE_PROVIDER_ROUTES[provider];
|
|
545
|
+
}
|
|
512
546
|
/** Human-readable provider names, shown in the DSH model picker. */
|
|
513
547
|
const TRAE_PROVIDER_DISPLAY_NAMES = {
|
|
514
548
|
cn: "Trae",
|
|
515
549
|
ai: "Trae Global"
|
|
516
550
|
};
|
|
551
|
+
/** Display name per provider id (the region table above only names the default). */
|
|
552
|
+
const TRAE_PROVIDER_ID_DISPLAY_NAMES = {
|
|
553
|
+
[TRAE_PROVIDER]: "Trae",
|
|
554
|
+
[TRAE_ENTERPRISE_PROVIDER]: "Trae 企业版",
|
|
555
|
+
[TRAE_AI_PROVIDER]: "Trae Global"
|
|
556
|
+
};
|
|
557
|
+
/** Display name of a provider id. */
|
|
558
|
+
function displayNameOfTraeProvider(provider) {
|
|
559
|
+
return TRAE_PROVIDER_ID_DISPLAY_NAMES[provider] ?? provider;
|
|
560
|
+
}
|
|
517
561
|
/** Region a provider route id belongs to. */
|
|
518
562
|
function regionOfTraeProvider(provider) {
|
|
519
|
-
for (const [region,
|
|
563
|
+
for (const [region, ids] of Object.entries(TRAE_PROVIDER_IDS)) if (ids.includes(provider)) return region;
|
|
520
564
|
}
|
|
521
565
|
const TRAE_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
522
566
|
const INERT_AUTH = {
|
|
@@ -549,10 +593,10 @@ const REQUEST_IMAGE_BUDGETS = {
|
|
|
549
593
|
* failing the entire provider route (`INVALID_MODEL_CONTEXT`).
|
|
550
594
|
*/
|
|
551
595
|
const FALLBACK_CONTEXT_WINDOW = 2e5;
|
|
552
|
-
function toPiModel(info, baseUrl, providerId) {
|
|
596
|
+
function toPiModel(info, baseUrl, providerId, withRate = true) {
|
|
553
597
|
return {
|
|
554
598
|
id: info.id,
|
|
555
|
-
name: traeModelDisplayName(info),
|
|
599
|
+
name: traeModelDisplayName(info, withRate),
|
|
556
600
|
api: "openai-completions",
|
|
557
601
|
provider: providerId,
|
|
558
602
|
baseUrl,
|
|
@@ -770,7 +814,12 @@ function createTraeAdapter(options) {
|
|
|
770
814
|
const providerId = options.provider ?? "trae";
|
|
771
815
|
const providerName = options.displayName ?? "Trae";
|
|
772
816
|
const generation = options.piAiGeneration ?? piAiRuntimeInfo().generation;
|
|
773
|
-
|
|
817
|
+
/**
|
|
818
|
+
* The enterprise provider shows no rate: the multiplier belongs to the
|
|
819
|
+
* consumer pool, and this provider bills the tenant pool.
|
|
820
|
+
*/
|
|
821
|
+
const showRate = options.provider !== TRAE_ENTERPRISE_PROVIDER;
|
|
822
|
+
const buildModels = () => options.catalog.current().map((info) => toPiModel(info, `${options.shim.baseUrl()}/v1`, providerId, showRate));
|
|
774
823
|
const provider = {
|
|
775
824
|
...createProvider({
|
|
776
825
|
id: providerId,
|
|
@@ -806,7 +855,7 @@ function createTraeAdapter(options) {
|
|
|
806
855
|
adapter: new PiAiAdapter({
|
|
807
856
|
profiles: () => profiles,
|
|
808
857
|
auth: INERT_AUTH,
|
|
809
|
-
resolveApiKey: async () => options.shim.token(),
|
|
858
|
+
resolveApiKey: async () => options.secret ?? options.shim.token(),
|
|
810
859
|
...options.resolveAttachments === void 0 ? {} : { resolveAttachments: options.resolveAttachments }
|
|
811
860
|
}),
|
|
812
861
|
invalidate() {
|
|
@@ -815,7 +864,113 @@ function createTraeAdapter(options) {
|
|
|
815
864
|
};
|
|
816
865
|
}
|
|
817
866
|
//#endregion
|
|
867
|
+
//#region src/region.ts
|
|
868
|
+
/** Every panel, in card tab order. */
|
|
869
|
+
const TRAE_PANELS = [
|
|
870
|
+
"cn",
|
|
871
|
+
"enterprise",
|
|
872
|
+
"ai"
|
|
873
|
+
];
|
|
874
|
+
/** The region whose credential/account pool a panel draws on. */
|
|
875
|
+
function regionOfPanel(panel) {
|
|
876
|
+
return panel === "ai" ? "ai" : "cn";
|
|
877
|
+
}
|
|
878
|
+
/** The provider id a panel publishes. */
|
|
879
|
+
function providerOfPanel(panel) {
|
|
880
|
+
if (panel === "enterprise") return "trae-enterprise";
|
|
881
|
+
return panel === "ai" ? "trae-global" : "trae";
|
|
882
|
+
}
|
|
883
|
+
/** The panel that owns a provider id, or undefined for an unknown id. */
|
|
884
|
+
function panelOfProvider(provider) {
|
|
885
|
+
return TRAE_PANELS.find((panel) => providerOfPanel(panel) === provider);
|
|
886
|
+
}
|
|
887
|
+
/** Narrow a stored settings key to a panel. */
|
|
888
|
+
function panelOfKey(value) {
|
|
889
|
+
return TRAE_PANELS.includes(value) ? value : void 0;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Verified gateway bases per region (docs/INTL_SG_EVIDENCE.md §2).
|
|
893
|
+
* The AI gateway is shared by both international installs (Trae desktop and
|
|
894
|
+
* TRAE SOLO); its mchost shards (`api16/api22-normal-alisg.mchost.guru`)
|
|
895
|
+
* answer the same bytes but stay internal — `coresg-normal.trae.ai` is the
|
|
896
|
+
* single stable entry point.
|
|
897
|
+
*/
|
|
898
|
+
const REGION_GATEWAYS = {
|
|
899
|
+
cn: {
|
|
900
|
+
chat: "https://trae-api-cn.mchost.guru",
|
|
901
|
+
remote: "https://solo.trae.cn/api/remote/v1",
|
|
902
|
+
pay: "https://api.trae.cn"
|
|
903
|
+
},
|
|
904
|
+
ai: {
|
|
905
|
+
chat: "https://coresg-normal.trae.ai",
|
|
906
|
+
remote: "https://coresg-normal.trae.ai/api/remote/v1",
|
|
907
|
+
pay: "https://growsg-normal.trae.ai"
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
/**
|
|
911
|
+
* Enterprise (tenant) chat gateway for a CN credential that belongs to a
|
|
912
|
+
* `tob_online` tenant.
|
|
913
|
+
*
|
|
914
|
+
* This is a SECOND chat gateway on the same credential, not a second region:
|
|
915
|
+
* the roster, the credential and the protocol are the same as `cn`, but a
|
|
916
|
+
* tenant account is billed against the enterprise pool when the request goes
|
|
917
|
+
* here. Measured 2026-10-17 on a live `Trae CN` tenant account: the same model
|
|
918
|
+
* served from this host does not move the personal `consumed_amount`, while
|
|
919
|
+
* the same call to `trae-api-cn.mchost.guru` charges it.
|
|
920
|
+
*
|
|
921
|
+
* Two wire differences, both load-bearing:
|
|
922
|
+
* - the request must carry a per-call `session_id` (the consumer gateway
|
|
923
|
+
* rejects it with `4001`), and
|
|
924
|
+
* - a refused call answers **HTTP 200** whose FIRST SSE frame is
|
|
925
|
+
* `event:error` with `code: 4001` — not an HTTP error status, so the
|
|
926
|
+
* refusal is only visible if the response body is inspected.
|
|
927
|
+
*/
|
|
928
|
+
const TRAE_ENTERPRISE_HOST = "https://console.enterprise.trae.cn";
|
|
929
|
+
/** Region for an edition label: the international installs belong to `ai`. */
|
|
930
|
+
function regionOfEdition(edition) {
|
|
931
|
+
return edition === "sg" || edition === "solo-sg" ? "ai" : "cn";
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Region from the credential's `userRegion` claim. The desktop storage spells
|
|
935
|
+
* it as an object (`{"region":"CN","_aiRegion":"CN"}`); the app logs also
|
|
936
|
+
* spell the bare value lowercase (`"sg"`). Both are accepted, case-blind.
|
|
937
|
+
*/
|
|
938
|
+
function regionOfUserRegion(value) {
|
|
939
|
+
const raw = typeof value === "object" && value !== null && !Array.isArray(value) ? value["region"] : value;
|
|
940
|
+
if (typeof raw !== "string") return void 0;
|
|
941
|
+
const lowered = raw.trim().toLowerCase();
|
|
942
|
+
if (lowered === "cn") return "cn";
|
|
943
|
+
if (lowered === "sg" || lowered === "ai") return "ai";
|
|
944
|
+
}
|
|
945
|
+
/** Region from a credential host (`.trae.ai` → ai, `.trae.cn`/`.trae.com.cn` → cn). */
|
|
946
|
+
function regionOfHost(host) {
|
|
947
|
+
if (host === void 0) return void 0;
|
|
948
|
+
const trimmed = host.trim();
|
|
949
|
+
if (trimmed === "") return void 0;
|
|
950
|
+
let hostname;
|
|
951
|
+
try {
|
|
952
|
+
hostname = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`).hostname;
|
|
953
|
+
} catch {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (hostname === "trae.ai" || hostname.endsWith(".trae.ai")) return "ai";
|
|
957
|
+
if (hostname === "trae.cn" || hostname.endsWith(".trae.cn") || hostname.endsWith(".trae.com.cn")) return "cn";
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Region of a credential: the `userRegion` claim wins, the host suffix is the
|
|
961
|
+
* fallback, and the edition label is the last resort. Every level is derived
|
|
962
|
+
* from data the credential itself carries, so no user configuration is needed.
|
|
963
|
+
*/
|
|
964
|
+
function regionOfCredential(credential) {
|
|
965
|
+
return regionOfUserRegion(credential.userRegion) ?? regionOfHost(credential.host) ?? regionOfEdition(credential.edition);
|
|
966
|
+
}
|
|
967
|
+
//#endregion
|
|
818
968
|
//#region src/status-paths.ts
|
|
969
|
+
/**
|
|
970
|
+
* Node-free constants and types shared by the Host and browser halves.
|
|
971
|
+
* Mirrors the `dsh-workbuddy-connect` status-route pattern so the plugin card
|
|
972
|
+
* stays consistent with that project's external presentation.
|
|
973
|
+
*/
|
|
819
974
|
/** Plugin-owned usage endpoint consumed by its browser half. */
|
|
820
975
|
const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
|
|
821
976
|
/** Plugin-owned live model refresh endpoint. */
|
|
@@ -857,6 +1012,49 @@ const TRAE_ACCOUNT_STATUS_PATH = "/plugins/dsh-connect-trae/account-status";
|
|
|
857
1012
|
*/
|
|
858
1013
|
const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
|
|
859
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
|
+
/**
|
|
860
1058
|
* Plugin-owned account transfer endpoint: batch export and import of a region's
|
|
861
1059
|
* stored credentials (issue #37).
|
|
862
1060
|
*
|
|
@@ -882,8 +1080,8 @@ const TRAE_REGIONS = ["cn", "ai"];
|
|
|
882
1080
|
* Address one region's status route. The two regions are separate provider
|
|
883
1081
|
* stacks; every card request carries the region whose tab the user is on.
|
|
884
1082
|
*/
|
|
885
|
-
function withTraeRegion(path,
|
|
886
|
-
return `${path}?${TRAE_REGION_PARAM}=${
|
|
1083
|
+
function withTraeRegion(path, panel) {
|
|
1084
|
+
return `${path}?${TRAE_REGION_PARAM}=${panel}`;
|
|
887
1085
|
}
|
|
888
1086
|
/**
|
|
889
1087
|
* Read the region parameter off a status-route URL. Absent means the domestic
|
|
@@ -894,7 +1092,7 @@ function regionOfTraeStatusUrl(url) {
|
|
|
894
1092
|
const at = url.indexOf("?");
|
|
895
1093
|
const value = at === -1 ? null : new URLSearchParams(url.slice(at + 1)).get(TRAE_REGION_PARAM);
|
|
896
1094
|
if (value === null || value === "") return "cn";
|
|
897
|
-
return
|
|
1095
|
+
return TRAE_PANELS.includes(value) ? value : void 0;
|
|
898
1096
|
}
|
|
899
1097
|
/**
|
|
900
1098
|
* Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
|
|
@@ -971,11 +1169,11 @@ function followAppLoginEnabled(configured) {
|
|
|
971
1169
|
* region's picks. Tolerates any stored shape (absent, non-object) by starting
|
|
972
1170
|
* from an empty document.
|
|
973
1171
|
*/
|
|
974
|
-
function nextRegionSlots(regions,
|
|
1172
|
+
function nextRegionSlots(regions, panel, slot) {
|
|
975
1173
|
const unwrapped = unwrapVolatileDeep(regions);
|
|
976
1174
|
return {
|
|
977
1175
|
...typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped) ? unwrapped : {},
|
|
978
|
-
[
|
|
1176
|
+
[panel]: slot
|
|
979
1177
|
};
|
|
980
1178
|
}
|
|
981
1179
|
/**
|
|
@@ -1000,8 +1198,8 @@ function regionsMapOf(value) {
|
|
|
1000
1198
|
return record;
|
|
1001
1199
|
}
|
|
1002
1200
|
/** One region's stored slot as a plain object; any other shape reads as empty. */
|
|
1003
|
-
function regionSlotOf(value,
|
|
1004
|
-
const slot = unwrapVolatile(regionsMapOf(value)[
|
|
1201
|
+
function regionSlotOf(value, panel) {
|
|
1202
|
+
const slot = unwrapVolatile(regionsMapOf(value)[panel]);
|
|
1005
1203
|
return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
|
|
1006
1204
|
}
|
|
1007
1205
|
/**
|
|
@@ -1014,8 +1212,10 @@ function regionSlotOf(value, region) {
|
|
|
1014
1212
|
* `value` may be the whole settings section or the `regions` map (see
|
|
1015
1213
|
* {@link regionsMapOf}).
|
|
1016
1214
|
*/
|
|
1017
|
-
function regionEnabledOf(value,
|
|
1018
|
-
|
|
1215
|
+
function regionEnabledOf(value, panel) {
|
|
1216
|
+
const stored = regionSlotOf(value, panel)["enabled"];
|
|
1217
|
+
if (typeof stored === "boolean") return stored;
|
|
1218
|
+
return panel !== "enterprise";
|
|
1019
1219
|
}
|
|
1020
1220
|
/**
|
|
1021
1221
|
* Build the next `regions` settings value for a provider on/off toggle. ONLY
|
|
@@ -1033,9 +1233,9 @@ function regionEnabledOf(value, region) {
|
|
|
1033
1233
|
* value is always the `regions` map, i.e. exactly what `settingsScope.set(
|
|
1034
1234
|
* 'regions', ...)` needs.
|
|
1035
1235
|
*/
|
|
1036
|
-
function nextRegionEnabled(value,
|
|
1037
|
-
return nextRegionSlots(regionsMapOf(value),
|
|
1038
|
-
...regionSlotOf(value,
|
|
1236
|
+
function nextRegionEnabled(value, panel, enabled) {
|
|
1237
|
+
return nextRegionSlots(regionsMapOf(value), panel, {
|
|
1238
|
+
...regionSlotOf(value, panel),
|
|
1039
1239
|
enabled
|
|
1040
1240
|
});
|
|
1041
1241
|
}
|
|
@@ -1052,8 +1252,8 @@ function nextRegionEnabled(value, region, enabled) {
|
|
|
1052
1252
|
* `value` may be the whole settings section or the `regions` map (see
|
|
1053
1253
|
* {@link regionsMapOf}).
|
|
1054
1254
|
*/
|
|
1055
|
-
function regionCreditPositionOf(value,
|
|
1056
|
-
return regionSlotOf(value,
|
|
1255
|
+
function regionCreditPositionOf(value, panel) {
|
|
1256
|
+
return regionSlotOf(value, panel)["creditPosition"] === "stats" ? "stats" : "composer";
|
|
1057
1257
|
}
|
|
1058
1258
|
/**
|
|
1059
1259
|
* Build the next `regions` settings value for one region's credit seat.
|
|
@@ -1066,9 +1266,9 @@ function regionCreditPositionOf(value, region) {
|
|
|
1066
1266
|
* value is always the `regions` map, i.e. exactly what `settingsScope.set(
|
|
1067
1267
|
* 'regions', ...)` needs.
|
|
1068
1268
|
*/
|
|
1069
|
-
function nextRegionCreditPosition(value,
|
|
1070
|
-
return nextRegionSlots(regionsMapOf(value),
|
|
1071
|
-
...regionSlotOf(value,
|
|
1269
|
+
function nextRegionCreditPosition(value, panel, position) {
|
|
1270
|
+
return nextRegionSlots(regionsMapOf(value), panel, {
|
|
1271
|
+
...regionSlotOf(value, panel),
|
|
1072
1272
|
creditPosition: position
|
|
1073
1273
|
});
|
|
1074
1274
|
}
|
|
@@ -1564,65 +1764,6 @@ function traeCliCandidates(platform = process.platform, home = homedir(), env =
|
|
|
1564
1764
|
return result;
|
|
1565
1765
|
}
|
|
1566
1766
|
//#endregion
|
|
1567
|
-
//#region src/region.ts
|
|
1568
|
-
/**
|
|
1569
|
-
* Verified gateway bases per region (docs/INTL_SG_EVIDENCE.md §2).
|
|
1570
|
-
* The AI gateway is shared by both international installs (Trae desktop and
|
|
1571
|
-
* TRAE SOLO); its mchost shards (`api16/api22-normal-alisg.mchost.guru`)
|
|
1572
|
-
* answer the same bytes but stay internal — `coresg-normal.trae.ai` is the
|
|
1573
|
-
* single stable entry point.
|
|
1574
|
-
*/
|
|
1575
|
-
const REGION_GATEWAYS = {
|
|
1576
|
-
cn: {
|
|
1577
|
-
chat: "https://trae-api-cn.mchost.guru",
|
|
1578
|
-
remote: "https://solo.trae.cn/api/remote/v1",
|
|
1579
|
-
pay: "https://api.trae.cn"
|
|
1580
|
-
},
|
|
1581
|
-
ai: {
|
|
1582
|
-
chat: "https://coresg-normal.trae.ai",
|
|
1583
|
-
remote: "https://coresg-normal.trae.ai/api/remote/v1",
|
|
1584
|
-
pay: "https://growsg-normal.trae.ai"
|
|
1585
|
-
}
|
|
1586
|
-
};
|
|
1587
|
-
/** Region for an edition label: the international installs belong to `ai`. */
|
|
1588
|
-
function regionOfEdition(edition) {
|
|
1589
|
-
return edition === "sg" || edition === "solo-sg" ? "ai" : "cn";
|
|
1590
|
-
}
|
|
1591
|
-
/**
|
|
1592
|
-
* Region from the credential's `userRegion` claim. The desktop storage spells
|
|
1593
|
-
* it as an object (`{"region":"CN","_aiRegion":"CN"}`); the app logs also
|
|
1594
|
-
* spell the bare value lowercase (`"sg"`). Both are accepted, case-blind.
|
|
1595
|
-
*/
|
|
1596
|
-
function regionOfUserRegion(value) {
|
|
1597
|
-
const raw = typeof value === "object" && value !== null && !Array.isArray(value) ? value["region"] : value;
|
|
1598
|
-
if (typeof raw !== "string") return void 0;
|
|
1599
|
-
const lowered = raw.trim().toLowerCase();
|
|
1600
|
-
if (lowered === "cn") return "cn";
|
|
1601
|
-
if (lowered === "sg" || lowered === "ai") return "ai";
|
|
1602
|
-
}
|
|
1603
|
-
/** Region from a credential host (`.trae.ai` → ai, `.trae.cn`/`.trae.com.cn` → cn). */
|
|
1604
|
-
function regionOfHost(host) {
|
|
1605
|
-
if (host === void 0) return void 0;
|
|
1606
|
-
const trimmed = host.trim();
|
|
1607
|
-
if (trimmed === "") return void 0;
|
|
1608
|
-
let hostname;
|
|
1609
|
-
try {
|
|
1610
|
-
hostname = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`).hostname;
|
|
1611
|
-
} catch {
|
|
1612
|
-
return;
|
|
1613
|
-
}
|
|
1614
|
-
if (hostname === "trae.ai" || hostname.endsWith(".trae.ai")) return "ai";
|
|
1615
|
-
if (hostname === "trae.cn" || hostname.endsWith(".trae.cn") || hostname.endsWith(".trae.com.cn")) return "cn";
|
|
1616
|
-
}
|
|
1617
|
-
/**
|
|
1618
|
-
* Region of a credential: the `userRegion` claim wins, the host suffix is the
|
|
1619
|
-
* fallback, and the edition label is the last resort. Every level is derived
|
|
1620
|
-
* from data the credential itself carries, so no user configuration is needed.
|
|
1621
|
-
*/
|
|
1622
|
-
function regionOfCredential(credential) {
|
|
1623
|
-
return regionOfUserRegion(credential.userRegion) ?? regionOfHost(credential.host) ?? regionOfEdition(credential.edition);
|
|
1624
|
-
}
|
|
1625
|
-
//#endregion
|
|
1626
1767
|
//#region src/auth.ts
|
|
1627
1768
|
/** Host used for CLI tokens, which carry no host claim of their own. */
|
|
1628
1769
|
const CLI_DEFAULT_HOST = "https://api.trae.cn";
|
|
@@ -1641,6 +1782,14 @@ const CLI_DEFAULT_HOST = "https://api.trae.cn";
|
|
|
1641
1782
|
* working and upgrades itself the next time a credential is written; it is
|
|
1642
1783
|
* never written again.
|
|
1643
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;
|
|
1644
1793
|
const OWN_VERSION = 2;
|
|
1645
1794
|
/** The superseded single-credential layout, still read for migration. */
|
|
1646
1795
|
const OWN_VERSION_SINGLE = 1;
|
|
@@ -1688,12 +1837,15 @@ function normalizeTraeCredential(raw, edition, source) {
|
|
|
1688
1837
|
const refreshExpiresAtMs = timeToMs(value["refreshExpiredAt"] ?? value["refreshExpiresAt"]);
|
|
1689
1838
|
const refreshToken = optionalString(value["refreshToken"]);
|
|
1690
1839
|
const userRegion = userRegionOf(value["userRegion"]);
|
|
1691
|
-
const
|
|
1840
|
+
const account = typeof value["account"] === "object" && value["account"] !== null && !Array.isArray(value["account"]) ? value["account"] : void 0;
|
|
1841
|
+
const accountName = optionalString(account?.["username"]);
|
|
1842
|
+
const tenantId = optionalString(account?.["tenant_id"]) ?? (typeof account?.["tenant_id"] === "number" ? String(account["tenant_id"]) : void 0);
|
|
1692
1843
|
return {
|
|
1693
1844
|
accessToken,
|
|
1694
1845
|
...refreshToken === void 0 ? {} : { refreshToken },
|
|
1695
1846
|
userId: optionalString(value["userId"]) ?? "",
|
|
1696
1847
|
...accountName === void 0 ? {} : { accountName },
|
|
1848
|
+
...tenantId === void 0 ? {} : { tenantId },
|
|
1697
1849
|
host: optionalString(value["host"]) ?? "",
|
|
1698
1850
|
...userRegion === void 0 ? {} : { userRegion },
|
|
1699
1851
|
expiresAtMs,
|
|
@@ -1702,10 +1854,38 @@ function normalizeTraeCredential(raw, edition, source) {
|
|
|
1702
1854
|
source
|
|
1703
1855
|
};
|
|
1704
1856
|
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Whether this account belongs to a Trae tenant, i.e. may bill the enterprise
|
|
1859
|
+
* pool.
|
|
1860
|
+
*
|
|
1861
|
+
* Evidence (2026-10-17, live): the tenant's own account carries
|
|
1862
|
+
* `account.tenant_id` / `tenant_name` and `scope: 'saas'`; the personal SOLO
|
|
1863
|
+
* account carries neither and reports `scope: 'marscode'`. Only the tenant one
|
|
1864
|
+
* streams on the enterprise gateway — the other answers `4019` risk control.
|
|
1865
|
+
*/
|
|
1866
|
+
function isEnterpriseAccount(credential) {
|
|
1867
|
+
return credential?.tenantId !== void 0 && credential.tenantId.trim() !== "";
|
|
1868
|
+
}
|
|
1705
1869
|
function traeAccountId(credential) {
|
|
1706
1870
|
const stable = `${credential.edition}\0${credential.userId || credential.accountName || "unknown"}`;
|
|
1707
1871
|
return createHash("sha256").update(stable).digest("hex").slice(0, 24);
|
|
1708
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
|
+
}
|
|
1709
1889
|
/** One stored credential, validated. Returns undefined for a malformed row. */
|
|
1710
1890
|
function credentialOfStored(stored) {
|
|
1711
1891
|
if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return void 0;
|
|
@@ -1718,7 +1898,10 @@ function credentialOfStored(stored) {
|
|
|
1718
1898
|
userId: record["userId"],
|
|
1719
1899
|
host: record["host"],
|
|
1720
1900
|
userRegion: record["userRegion"],
|
|
1721
|
-
account: record["accountName"] === void 0 ? void 0 : {
|
|
1901
|
+
account: record["accountName"] === void 0 && record["tenantId"] === void 0 ? void 0 : {
|
|
1902
|
+
...record["accountName"] === void 0 ? {} : { username: record["accountName"] },
|
|
1903
|
+
...record["tenantId"] === void 0 ? {} : { tenant_id: record["tenantId"] }
|
|
1904
|
+
},
|
|
1722
1905
|
expiredAt: record["expiresAtMs"],
|
|
1723
1906
|
refreshExpiredAt: record["refreshExpiredAtMs"]
|
|
1724
1907
|
}, edition, "dsh");
|
|
@@ -1773,17 +1956,52 @@ var TraeCredentialStore = class {
|
|
|
1773
1956
|
edition;
|
|
1774
1957
|
accountId;
|
|
1775
1958
|
region;
|
|
1959
|
+
requireTenant;
|
|
1960
|
+
hideTenantAccounts;
|
|
1776
1961
|
ownPathExplicit;
|
|
1777
1962
|
legacyOwnPath;
|
|
1778
1963
|
legacyOwnPathExplicit;
|
|
1779
1964
|
refresh;
|
|
1780
1965
|
refreshMarginMs;
|
|
1781
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;
|
|
1782
1998
|
constructor(options) {
|
|
1783
1999
|
this.storagePathOverride = options.storagePath;
|
|
1784
2000
|
this.edition = options.edition ?? "auto";
|
|
1785
2001
|
this.accountId = options.accountId;
|
|
1786
2002
|
this.region = options.region;
|
|
2003
|
+
this.requireTenant = options.requireTenant === true;
|
|
2004
|
+
this.hideTenantAccounts = options.hideTenantAccounts === true;
|
|
1787
2005
|
this.ownPathExplicit = options.ownPath;
|
|
1788
2006
|
this.legacyOwnPath = options.legacyOwnPath ?? legacyTraeOwnAuthPath();
|
|
1789
2007
|
this.legacyOwnPathExplicit = options.legacyOwnPath;
|
|
@@ -1867,7 +2085,9 @@ var TraeCredentialStore = class {
|
|
|
1867
2085
|
* the plugin performed is never discarded by this preference.
|
|
1868
2086
|
*/
|
|
1869
2087
|
preferred(credentials) {
|
|
1870
|
-
|
|
2088
|
+
const eligible = this.requireTenant ? credentials.filter((credential) => isEnterpriseAccount(credential)) : credentials;
|
|
2089
|
+
if (eligible.length === 0) return void 0;
|
|
2090
|
+
return eligible.find((credential) => credential.source === "desktop") ?? eligible[0];
|
|
1871
2091
|
}
|
|
1872
2092
|
/**
|
|
1873
2093
|
* The account the Trae app is signed in to right now, when that differs from
|
|
@@ -1891,6 +2111,7 @@ var TraeCredentialStore = class {
|
|
|
1891
2111
|
id: liveId,
|
|
1892
2112
|
accountName: live.accountName ?? (live.userId || `${live.edition} account`),
|
|
1893
2113
|
edition: live.edition,
|
|
2114
|
+
enterprise: isEnterpriseAccount(live),
|
|
1894
2115
|
region: regionOfCredential(live),
|
|
1895
2116
|
source: live.source,
|
|
1896
2117
|
tokenExpiresAtMs: live.expiresAtMs,
|
|
@@ -1898,25 +2119,135 @@ var TraeCredentialStore = class {
|
|
|
1898
2119
|
selected: false
|
|
1899
2120
|
};
|
|
1900
2121
|
}
|
|
2122
|
+
/**
|
|
2123
|
+
* The accounts this store is willing to OFFER.
|
|
2124
|
+
*
|
|
2125
|
+
* Scoped to who this store may bill:
|
|
2126
|
+
* - a consumer store withholds tenant accounts (the enterprise pool is a
|
|
2127
|
+
* separate tab and a separate billing decision), and
|
|
2128
|
+
* - a tenant store withholds personal accounts (they cannot bill that
|
|
2129
|
+
* pool; the tenant gateway answers their chat with 4019).
|
|
2130
|
+
*
|
|
2131
|
+
* Either filter backs off when it would hide EVERYTHING, so a machine whose
|
|
2132
|
+
* only login is on the other side keeps working instead of reading as
|
|
2133
|
+
* "signed out".
|
|
2134
|
+
*/
|
|
2135
|
+
offered(discovered) {
|
|
2136
|
+
const eligible = this.requireTenant ? discovered.filter((credential) => isEnterpriseAccount(credential)) : this.hideTenantAccounts ? discovered.filter((credential) => !isEnterpriseAccount(credential)) : discovered;
|
|
2137
|
+
return eligible.length > 0 ? eligible : discovered;
|
|
2138
|
+
}
|
|
1901
2139
|
async accounts() {
|
|
1902
|
-
const
|
|
1903
|
-
const
|
|
1904
|
-
const
|
|
2140
|
+
const discovered = await this.readAll();
|
|
2141
|
+
const offered = this.offered(discovered);
|
|
2142
|
+
const inUse = this.inUse(discovered, offered);
|
|
2143
|
+
/**
|
|
2144
|
+
* Never hide the account we are actually billing.
|
|
2145
|
+
*
|
|
2146
|
+
* An explicit binding outranks the display filter (`inUse` honours it even
|
|
2147
|
+
* when the offered list withholds it), and a list whose active row is
|
|
2148
|
+
* invisible is worse than no list: the card's "save the selected account"
|
|
2149
|
+
* press reads `selected`, so a hidden binding would be rewritten to whichever
|
|
2150
|
+
* account merely happened to be DISPLAYED — silently moving the user's
|
|
2151
|
+
* billing to an account they never chose. Re-appending it costs one extra
|
|
2152
|
+
* row and makes the card tell the truth.
|
|
2153
|
+
*/
|
|
2154
|
+
const credentials = inUse !== void 0 && !offered.includes(inUse) ? [...offered, inUse] : offered;
|
|
2155
|
+
const inUseId = inUse === void 0 ? void 0 : traeAccountId(inUse);
|
|
1905
2156
|
return credentials.map((credential) => ({
|
|
1906
2157
|
id: traeAccountId(credential),
|
|
1907
2158
|
accountName: credential.accountName ?? (credential.userId || `${credential.edition} account`),
|
|
1908
2159
|
edition: credential.edition,
|
|
2160
|
+
enterprise: isEnterpriseAccount(credential),
|
|
1909
2161
|
region: regionOfCredential(credential),
|
|
1910
2162
|
source: credential.source,
|
|
1911
2163
|
tokenExpiresAtMs: credential.expiresAtMs,
|
|
1912
2164
|
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
|
|
1913
|
-
selected:
|
|
2165
|
+
selected: inUseId !== void 0 && traeAccountId(credential) === inUseId
|
|
1914
2166
|
}));
|
|
1915
2167
|
}
|
|
2168
|
+
/**
|
|
2169
|
+
* The credential this store bills, given every credential it can see.
|
|
2170
|
+
*
|
|
2171
|
+
* This is the ONE resolution rule, and it exists so that `current()` and
|
|
2172
|
+
* `accounts()` cannot disagree: `current()` applies it to `readAll()`, and
|
|
2173
|
+
* `accounts()` applies the SAME verdict to decide which row reads as
|
|
2174
|
+
* `selected`. Deriving that flag independently is exactly how the two drifted
|
|
2175
|
+
* apart — and a card that marks a different account than the one the chat
|
|
2176
|
+
* bills is worse than no card at all, because a press that "saves the
|
|
2177
|
+
* selected account" then rewrites the binding to the account that was only
|
|
2178
|
+
* ever DISPLAYED.
|
|
2179
|
+
*
|
|
2180
|
+
* Order of business:
|
|
2181
|
+
* 1. No binding — the detected default (`preferred`) is in use, chosen from
|
|
2182
|
+
* the OFFERED list rather than from every login on the machine. A
|
|
2183
|
+
* consumer store that withholds tenant accounts must not default its
|
|
2184
|
+
* billing to the tenant pool merely because that login sorts first;
|
|
2185
|
+
* withholding is a billing decision, not a display detail.
|
|
2186
|
+
* 2. A binding — honour it verbatim, even when the OFFERED list hides that
|
|
2187
|
+
* account. Resolution is never scoped by `hideTenantAccounts`: the user's
|
|
2188
|
+
* explicit choice outranks a display filter.
|
|
2189
|
+
* 3. A tenant-scoped store cannot bill through a personal account, and the
|
|
2190
|
+
* tenant gateway only reveals that at the first chat (`4019`). Honour the
|
|
2191
|
+
* binding when it is eligible; otherwise fall back to the preferred
|
|
2192
|
+
* tenant account, which is the one the user's enterprise tab is meant to
|
|
2193
|
+
* use.
|
|
2194
|
+
* 4. A saved account can disappear when Trae replaces its local login. Do
|
|
2195
|
+
* NOT silently fall back to a different account: that would bill a
|
|
2196
|
+
* different account than the one the user explicitly selected. Return
|
|
2197
|
+
* undefined so the caller surfaces "no signed-in account" and the user
|
|
2198
|
+
* can re-select, instead of the plugin quietly switching accounts behind
|
|
2199
|
+
* their back.
|
|
2200
|
+
*/
|
|
2201
|
+
inUse(discovered, offered) {
|
|
2202
|
+
if (this.accountId === void 0) return this.preferred(offered);
|
|
2203
|
+
const selected = discovered.find((credential) => traeAccountId(credential) === this.accountId);
|
|
2204
|
+
if (this.requireTenant && !isEnterpriseAccount(selected)) return this.preferred(offered);
|
|
2205
|
+
return selected;
|
|
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
|
+
}
|
|
1916
2245
|
async current() {
|
|
1917
|
-
const
|
|
1918
|
-
|
|
1919
|
-
|
|
2246
|
+
const discovered = await this.readAll();
|
|
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);
|
|
1920
2251
|
}
|
|
1921
2252
|
/**
|
|
1922
2253
|
* The stored credential of one specific account, without changing which
|
|
@@ -1938,6 +2269,41 @@ var TraeCredentialStore = class {
|
|
|
1938
2269
|
return (await this.readAll()).find((credential) => traeAccountId(credential) === accountId);
|
|
1939
2270
|
}
|
|
1940
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
|
+
/**
|
|
1941
2307
|
* Store imported credentials (issue #37), replacing any entry with the same
|
|
1942
2308
|
* account id and leaving every other entry alone.
|
|
1943
2309
|
*
|
|
@@ -2363,7 +2729,7 @@ async function refreshTraeCredential(credential, signal, device) {
|
|
|
2363
2729
|
}
|
|
2364
2730
|
//#endregion
|
|
2365
2731
|
//#region src/identity.ts
|
|
2366
|
-
function nonEmpty(value) {
|
|
2732
|
+
function nonEmpty$1(value) {
|
|
2367
2733
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
2368
2734
|
}
|
|
2369
2735
|
function deviceCenterId(storage) {
|
|
@@ -2388,8 +2754,8 @@ function deviceKeyPairOf(storage) {
|
|
|
2388
2754
|
const plaintext = parseTraeAuthValue(encoded);
|
|
2389
2755
|
if (typeof plaintext !== "object" || plaintext === null || Array.isArray(plaintext)) return void 0;
|
|
2390
2756
|
const value = plaintext;
|
|
2391
|
-
const privateKeyPEM = nonEmpty(value["privateKeyPEM"]);
|
|
2392
|
-
const publicKeyPEM = nonEmpty(value["publicKeyPEM"]);
|
|
2757
|
+
const privateKeyPEM = nonEmpty$1(value["privateKeyPEM"]);
|
|
2758
|
+
const publicKeyPEM = nonEmpty$1(value["publicKeyPEM"]);
|
|
2393
2759
|
return privateKeyPEM === void 0 || publicKeyPEM === void 0 ? void 0 : {
|
|
2394
2760
|
privateKeyPEM,
|
|
2395
2761
|
publicKeyPEM
|
|
@@ -2405,14 +2771,14 @@ async function readTraeIdentity(candidate, options = {}) {
|
|
|
2405
2771
|
const env = options.env ?? process.env;
|
|
2406
2772
|
const storage = JSON.parse(await readFile(candidate.path, "utf8"));
|
|
2407
2773
|
const appRoot = dirname(dirname(dirname(candidate.path)));
|
|
2408
|
-
const machineFile = nonEmpty(await readFile(join(appRoot, "machineid"), "utf8").catch(() => ""));
|
|
2409
|
-
const telemetryMachine = nonEmpty(storage["telemetry.machineId"]);
|
|
2410
|
-
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"]);
|
|
2411
2777
|
const dcDevice = deviceCenterId(storage);
|
|
2412
2778
|
const machineId = telemetryMachine ?? machineFile;
|
|
2413
2779
|
if (machineId === void 0) throw new Error(`Trae ${candidate.edition} has no stable machine identity`);
|
|
2414
2780
|
const deviceId = dcDevice ?? devDevice ?? createHash("sha256").update(machineId).digest("hex").slice(0, 32);
|
|
2415
|
-
const buildVersion = nonEmpty(storage["iCubeLastVersion"]);
|
|
2781
|
+
const buildVersion = nonEmpty$1(storage["iCubeLastVersion"]);
|
|
2416
2782
|
const appName = {
|
|
2417
2783
|
cn: "Trae CN",
|
|
2418
2784
|
sg: "Trae",
|
|
@@ -2432,18 +2798,18 @@ async function readTraeIdentity(candidate, options = {}) {
|
|
|
2432
2798
|
product = JSON.parse(await readFile(path, "utf8"));
|
|
2433
2799
|
break;
|
|
2434
2800
|
} catch {}
|
|
2435
|
-
const appVersion = nonEmpty(product["appVersion"]);
|
|
2436
|
-
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;
|
|
2437
2803
|
const deviceCpu = cpus()[0]?.model.split(" ")[0];
|
|
2438
2804
|
const osVersion = `${platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform} ${release()}`;
|
|
2439
2805
|
const deviceName = (() => {
|
|
2440
2806
|
try {
|
|
2441
|
-
return nonEmpty(userInfo().username);
|
|
2807
|
+
return nonEmpty$1(userInfo().username);
|
|
2442
2808
|
} catch {
|
|
2443
2809
|
return;
|
|
2444
2810
|
}
|
|
2445
2811
|
})();
|
|
2446
|
-
const deviceModel = nonEmpty(env["TRAE_DEVICE_MODEL"]);
|
|
2812
|
+
const deviceModel = nonEmpty$1(env["TRAE_DEVICE_MODEL"]);
|
|
2447
2813
|
const deviceKeyPair = deviceKeyPairOf(storage);
|
|
2448
2814
|
return {
|
|
2449
2815
|
edition: candidate.edition,
|
|
@@ -2520,12 +2886,12 @@ async function readTraeCliIdentity(edition, options = {}) {
|
|
|
2520
2886
|
const cliHome = join(home, CLI_HOME_BY_EDITION[edition]);
|
|
2521
2887
|
const argv = await readJsonFile(join(cliHome, "argv.json"));
|
|
2522
2888
|
const version = await readJsonFile(join(cliHome, "builtin", "ide_version.json"));
|
|
2523
|
-
const crashReporterId = nonEmpty(argv?.["crash-reporter-id"]);
|
|
2524
|
-
const host = nonEmpty(env["HOSTNAME"]) ?? await readHostname() ?? "unknown-host";
|
|
2525
|
-
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";
|
|
2526
2892
|
const deviceId = crashReporterId ?? createHash("sha256").update(`trae-cli\0${host}\0${user}`).digest("hex").slice(0, 32);
|
|
2527
2893
|
const machineId = createHash("sha256").update(`trae-cli-machine\0${deviceId}\0${host}`).digest("hex");
|
|
2528
|
-
const appVersion = nonEmpty(version?.["version"]);
|
|
2894
|
+
const appVersion = nonEmpty$1(version?.["version"]);
|
|
2529
2895
|
const deviceCpu = cpus()[0]?.model.split(" ")[0];
|
|
2530
2896
|
const osVersion = `${platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform} ${release()}`;
|
|
2531
2897
|
return {
|
|
@@ -2551,7 +2917,7 @@ async function readJsonFile(path) {
|
|
|
2551
2917
|
async function readHostname() {
|
|
2552
2918
|
try {
|
|
2553
2919
|
const { hostname } = await import("node:os");
|
|
2554
|
-
return nonEmpty(hostname());
|
|
2920
|
+
return nonEmpty$1(hostname());
|
|
2555
2921
|
} catch {
|
|
2556
2922
|
return;
|
|
2557
2923
|
}
|
|
@@ -2611,6 +2977,29 @@ const STATUS_BY_KIND = {
|
|
|
2611
2977
|
client: 400,
|
|
2612
2978
|
unconfigured: 503
|
|
2613
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;
|
|
2614
3003
|
function hostnameOfHost(host) {
|
|
2615
3004
|
let hostname = host.trim().toLowerCase();
|
|
2616
3005
|
if (hostname.startsWith("[")) {
|
|
@@ -2664,7 +3053,19 @@ function readBody(req) {
|
|
|
2664
3053
|
});
|
|
2665
3054
|
}
|
|
2666
3055
|
function createTraeShim(options) {
|
|
2667
|
-
|
|
3056
|
+
/**
|
|
3057
|
+
* Resolve the route table. The single-client form becomes a one-entry table
|
|
3058
|
+
* labelled `solo`, so existing call sites and tests keep one code path.
|
|
3059
|
+
*/
|
|
3060
|
+
const routes = options.routes !== void 0 ? options.routes.map((route) => ({
|
|
3061
|
+
...route,
|
|
3062
|
+
secret: randomBytes(32).toString("base64url")
|
|
3063
|
+
})) : [{
|
|
3064
|
+
label: "solo",
|
|
3065
|
+
client: options.client,
|
|
3066
|
+
secret: randomBytes(32).toString("base64url")
|
|
3067
|
+
}];
|
|
3068
|
+
const secret = routes[0]?.secret ?? randomBytes(32).toString("base64url");
|
|
2668
3069
|
const sockets = /* @__PURE__ */ new Set();
|
|
2669
3070
|
const server = createServer((req, res) => {
|
|
2670
3071
|
handle(req, res);
|
|
@@ -2678,18 +3079,65 @@ function createTraeShim(options) {
|
|
|
2678
3079
|
server.once("error", reject);
|
|
2679
3080
|
});
|
|
2680
3081
|
server.listen(0, "127.0.0.1");
|
|
2681
|
-
|
|
3082
|
+
/** Route whose secret matches the request's bearer, or `undefined`. */
|
|
3083
|
+
function routeOf(req) {
|
|
2682
3084
|
const match = typeof req.headers.authorization === "string" ? /^Bearer\s+(.+)$/i.exec(req.headers.authorization.trim()) : null;
|
|
2683
|
-
if (match === null) return
|
|
3085
|
+
if (match === null) return void 0;
|
|
2684
3086
|
const actual = Buffer.from(match[1] ?? "");
|
|
2685
|
-
const
|
|
2686
|
-
|
|
3087
|
+
for (const route of routes) {
|
|
3088
|
+
const expected = Buffer.from(route.secret);
|
|
3089
|
+
if (actual.length === expected.length && timingSafeEqual(actual, expected)) return route;
|
|
3090
|
+
}
|
|
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
|
+
}
|
|
2687
3134
|
}
|
|
2688
3135
|
async function handle(req, res) {
|
|
2689
3136
|
try {
|
|
2690
3137
|
if (!hostIsLoopback(req.headers.host)) return writeError(res, 403, "host_not_allowed", "Host must be loopback");
|
|
2691
3138
|
if (!originIsLoopback(req.headers.origin)) return writeError(res, 403, "origin_not_allowed", "Origin must be loopback");
|
|
2692
|
-
|
|
3139
|
+
const route = routeOf(req);
|
|
3140
|
+
if (route === void 0) return writeError(res, 401, "unauthorized", "Missing or invalid bearer");
|
|
2693
3141
|
const url = req.url ?? "/";
|
|
2694
3142
|
if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) return writeJson(res, 200, { ok: true });
|
|
2695
3143
|
if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) return writeJson(res, 200, {
|
|
@@ -2723,8 +3171,8 @@ function createTraeShim(options) {
|
|
|
2723
3171
|
const abort = () => controller.abort();
|
|
2724
3172
|
req.once("aborted", abort);
|
|
2725
3173
|
req.socket.once("close", abort);
|
|
2726
|
-
const result = await
|
|
2727
|
-
if (
|
|
3174
|
+
const result = await chatWithFailover(route, raw, controller.signal);
|
|
3175
|
+
if (result.kind === "failed") return writeError(res, result.status, result.code, result.message);
|
|
2728
3176
|
res.writeHead(200, {
|
|
2729
3177
|
"Content-Type": "text/event-stream",
|
|
2730
3178
|
"Cache-Control": "no-cache",
|
|
@@ -2759,6 +3207,12 @@ function createTraeShim(options) {
|
|
|
2759
3207
|
return `http://127.0.0.1:${address.port}`;
|
|
2760
3208
|
},
|
|
2761
3209
|
token: () => secret,
|
|
3210
|
+
tokenOf(label) {
|
|
3211
|
+
if (label === void 0) return secret;
|
|
3212
|
+
const route = routes.find((entry) => entry.label === label);
|
|
3213
|
+
if (route === void 0) throw new Error(`trae shim has no route named ${label}`);
|
|
3214
|
+
return route.secret;
|
|
3215
|
+
},
|
|
2762
3216
|
close: () => new Promise((resolve, reject) => {
|
|
2763
3217
|
for (const socket of sockets) socket.destroy();
|
|
2764
3218
|
server.close((error) => error === void 0 ? resolve() : reject(error));
|
|
@@ -3107,7 +3561,7 @@ var TraeSoloUpstreamClient = class {
|
|
|
3107
3561
|
async fetchModels(signal) {
|
|
3108
3562
|
const [credential, identity] = await Promise.all([this.options.credential(), this.options.identity()]);
|
|
3109
3563
|
const region = regionOfCredential(credential);
|
|
3110
|
-
const base = this.options.baseUrl ??
|
|
3564
|
+
const base = this.options.baseUrl ?? this.chatBaseOf(credential);
|
|
3111
3565
|
const headers = {
|
|
3112
3566
|
...buildTraeCnHeaders(credential, identity),
|
|
3113
3567
|
Accept: "application/json"
|
|
@@ -3193,7 +3647,19 @@ var TraeSoloUpstreamClient = class {
|
|
|
3193
3647
|
}
|
|
3194
3648
|
const [credential, identity] = await Promise.all([this.options.credential(), this.options.identity()]);
|
|
3195
3649
|
const headers = buildTraeCnHeaders(credential, identity);
|
|
3196
|
-
const base = this.options.baseUrl ??
|
|
3650
|
+
const base = this.options.baseUrl ?? this.chatBaseOf(credential);
|
|
3651
|
+
if (this.options.route === "enterprise") try {
|
|
3652
|
+
const parsed = JSON.parse(prepared);
|
|
3653
|
+
if (parsed["session_id"] === void 0) parsed["session_id"] = randomUUID();
|
|
3654
|
+
prepared = JSON.stringify(parsed);
|
|
3655
|
+
} catch {
|
|
3656
|
+
return {
|
|
3657
|
+
ok: false,
|
|
3658
|
+
status: 400,
|
|
3659
|
+
kind: "client",
|
|
3660
|
+
message: "invalid JSON request"
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3197
3663
|
let response;
|
|
3198
3664
|
try {
|
|
3199
3665
|
response = await this.fetchImpl(traeEndpoint(base, TRAE_SOLO_CHAT_PATH), {
|
|
@@ -3210,10 +3676,36 @@ var TraeSoloUpstreamClient = class {
|
|
|
3210
3676
|
message: `transport error: ${String(error)}`
|
|
3211
3677
|
};
|
|
3212
3678
|
}
|
|
3213
|
-
if (response.ok)
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3679
|
+
if (response.ok) {
|
|
3680
|
+
/**
|
|
3681
|
+
* The enterprise gateway reports a refusal as **HTTP 200** whose first
|
|
3682
|
+
* SSE frame is `event:error`. Handing that stream back as a success would
|
|
3683
|
+
* surface the refusal as an empty assistant turn, so it is converted to
|
|
3684
|
+
* a real error here — the same shape the consumer gateway uses for its
|
|
3685
|
+
* non-2xx failures.
|
|
3686
|
+
*/
|
|
3687
|
+
if (this.options.route !== "enterprise") return {
|
|
3688
|
+
ok: true,
|
|
3689
|
+
response
|
|
3690
|
+
};
|
|
3691
|
+
const classified = await classifyEnterpriseResponse(response);
|
|
3692
|
+
if ("response" in classified) return {
|
|
3693
|
+
ok: true,
|
|
3694
|
+
response: classified.response
|
|
3695
|
+
};
|
|
3696
|
+
this.options.log?.("dsh-connect-trae: enterprise llm_utils_chat refused", {
|
|
3697
|
+
status: 200,
|
|
3698
|
+
model: JSON.parse(prepared)["model"],
|
|
3699
|
+
configName: JSON.parse(prepared)["config_name"],
|
|
3700
|
+
body: classified.refusal
|
|
3701
|
+
});
|
|
3702
|
+
return {
|
|
3703
|
+
ok: false,
|
|
3704
|
+
status: 200,
|
|
3705
|
+
kind: "client",
|
|
3706
|
+
message: classified.refusal
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3217
3709
|
const text = (await response.text()).slice(0, 1024);
|
|
3218
3710
|
this.options.log?.("dsh-connect-trae: llm_utils_chat rejected", {
|
|
3219
3711
|
status: response.status,
|
|
@@ -3229,10 +3721,80 @@ var TraeSoloUpstreamClient = class {
|
|
|
3229
3721
|
message: text || `Trae SOLO returned HTTP ${response.status}`
|
|
3230
3722
|
};
|
|
3231
3723
|
}
|
|
3724
|
+
/** Chat gateway for this client's pinned route. */
|
|
3725
|
+
chatBaseOf(credential) {
|
|
3726
|
+
if (this.options.route === "enterprise") return TRAE_ENTERPRISE_HOST;
|
|
3727
|
+
return REGION_GATEWAYS[regionOfCredential(credential)].chat;
|
|
3728
|
+
}
|
|
3232
3729
|
};
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3730
|
+
/**
|
|
3731
|
+
* Return the refusal frame of an enterprise 200, or `undefined` when the stream
|
|
3732
|
+
* is a real answer.
|
|
3733
|
+
*
|
|
3734
|
+
* Reads (and re-plays) only the first SSE frame: a refusal is emitted before
|
|
3735
|
+
* any output token, so peeking that far is enough to classify the call, and
|
|
3736
|
+
* the stream is rebuilt so a caller that got `undefined` still sees every byte.
|
|
3737
|
+
*/
|
|
3738
|
+
function concatStream(head, reader) {
|
|
3739
|
+
return new ReadableStream({ start(controller) {
|
|
3740
|
+
if (head !== "") controller.enqueue(new TextEncoder().encode(head));
|
|
3741
|
+
const drain = () => {
|
|
3742
|
+
reader.read().then((result) => {
|
|
3743
|
+
if (result.done) {
|
|
3744
|
+
controller.close();
|
|
3745
|
+
return;
|
|
3746
|
+
}
|
|
3747
|
+
controller.enqueue(result.value);
|
|
3748
|
+
drain();
|
|
3749
|
+
}).catch((error) => controller.error(error));
|
|
3750
|
+
};
|
|
3751
|
+
drain();
|
|
3752
|
+
} });
|
|
3753
|
+
}
|
|
3754
|
+
/**
|
|
3755
|
+
* Classify an enterprise 200.
|
|
3756
|
+
*
|
|
3757
|
+
* Returns `{ refusal }` when the first SSE frame is `event:error`, otherwise
|
|
3758
|
+
* `{ response }` carrying a REBUILT response whose body re-emits every byte —
|
|
3759
|
+
* including the peeked frame. A refusal is emitted before any output token, so
|
|
3760
|
+
* peeking one frame is enough to classify the call.
|
|
3761
|
+
*
|
|
3762
|
+
* The response is rebuilt rather than patched: the original's body has been
|
|
3763
|
+
* partially read, so its `text()`/`json()` would keep failing with "Body has
|
|
3764
|
+
* already been read" even after `body` is swapped.
|
|
3765
|
+
*/
|
|
3766
|
+
async function classifyEnterpriseResponse(response) {
|
|
3767
|
+
const body = response.body;
|
|
3768
|
+
if (body === null) return { response };
|
|
3769
|
+
const reader = body.getReader();
|
|
3770
|
+
const decoder = new TextDecoder();
|
|
3771
|
+
let head = "";
|
|
3772
|
+
try {
|
|
3773
|
+
while (!head.includes("\n\n")) {
|
|
3774
|
+
const { done, value } = await reader.read();
|
|
3775
|
+
if (done) break;
|
|
3776
|
+
head += decoder.decode(value, { stream: true });
|
|
3777
|
+
if (head.length > 8192) break;
|
|
3778
|
+
}
|
|
3779
|
+
} catch {
|
|
3780
|
+
return { response };
|
|
3781
|
+
}
|
|
3782
|
+
const frame = head.split("\n\n")[0] ?? "";
|
|
3783
|
+
const event = /^event:(\S+)/m.exec(frame)?.[1];
|
|
3784
|
+
const data = frame.split("\n").find((line) => line.startsWith("data:"))?.slice(5).trim() ?? "";
|
|
3785
|
+
if (event !== "error") return { response: new Response(concatStream(head, reader), {
|
|
3786
|
+
status: response.status,
|
|
3787
|
+
statusText: response.statusText,
|
|
3788
|
+
headers: response.headers
|
|
3789
|
+
}) };
|
|
3790
|
+
try {
|
|
3791
|
+
await reader.cancel();
|
|
3792
|
+
} catch {}
|
|
3793
|
+
return { refusal: data === "" ? "enterprise gateway refused the request" : data };
|
|
3794
|
+
}
|
|
3795
|
+
//#endregion
|
|
3796
|
+
//#region src/sse.ts
|
|
3797
|
+
/** Incremental SSE decoder supporting CRLF, chunk splits and multi-line data. */
|
|
3236
3798
|
var SseDecoder = class {
|
|
3237
3799
|
buffer = "";
|
|
3238
3800
|
event;
|
|
@@ -3370,6 +3932,13 @@ function decodeTraeEvent(event) {
|
|
|
3370
3932
|
* two different statements, and a user hits the second without touching the
|
|
3371
3933
|
* first (issue #33).
|
|
3372
3934
|
* - `4023` — the model name is unknown on the route that was asked.
|
|
3935
|
+
* - `4222` — the ENTERPRISE tenant's allowance is exhausted. Measured
|
|
3936
|
+
* 2026-10-17 on a live tenant account whose enterprise credit ran out: the
|
|
3937
|
+
* gateway answered HTTP 200 with `{"code":4222,"message":""}` for a model
|
|
3938
|
+
* that streamed normally moments earlier (and still streams on the consumer
|
|
3939
|
+
* gateway). Like `4008` it is a billing fact rather than a config one, but
|
|
3940
|
+
* it is specific to the tenant pool -- the consumer balance is unaffected,
|
|
3941
|
+
* which is why it must not be folded into the `4008` reading.
|
|
3373
3942
|
* - `2001` — the upstream has not published its function config for the route
|
|
3374
3943
|
* that was asked. Evidence is issue #39: on a live CN account every SOLO
|
|
3375
3944
|
* function's `get_detail_param` answered HTTP 200 with `config_info_list:
|
|
@@ -3387,7 +3956,8 @@ const TRAE_ERROR_HINTS = {
|
|
|
3387
3956
|
4001: "Trae does not serve this model under the SOLO function the request used (config_name rejected)",
|
|
3388
3957
|
4008: "Trae reports the account's SOLO quota is exhausted for this request",
|
|
3389
3958
|
4011: "Trae throttled the SOLO channel for this account (a rate limit, not the credit balance)",
|
|
3390
|
-
4023: "Trae does not recognise this model name on the SOLO route that was asked"
|
|
3959
|
+
4023: "Trae does not recognise this model name on the SOLO route that was asked",
|
|
3960
|
+
4222: "The enterprise tenant allowance is exhausted (tenant spend is separate from the personal credit balance; the consumer gateway still has its own)"
|
|
3391
3961
|
};
|
|
3392
3962
|
/** `extra` is a JSON *string* on the wire; unwrap it to read `plan`. */
|
|
3393
3963
|
function traeErrorPlan(extra) {
|
|
@@ -3717,14 +4287,29 @@ var TraeSoloBridge = class {
|
|
|
3717
4287
|
try {
|
|
3718
4288
|
const input = JSON.parse(bodyJson);
|
|
3719
4289
|
if (typeof input["model"] === "string" && input["model"] !== "") model = input["model"];
|
|
3720
|
-
const
|
|
4290
|
+
const entryKey = model.trim().toLowerCase();
|
|
4291
|
+
const entry = this.catalog?.current().find((item) => item.id === model || item.id.trim().toLowerCase() === entryKey);
|
|
3721
4292
|
const fromCatalog = entry?.wireConfigName === void 0 ? void 0 : {
|
|
3722
4293
|
configName: entry.wireConfigName,
|
|
3723
4294
|
...entry.wireFunction === void 0 ? {} : { function: entry.wireFunction }
|
|
3724
4295
|
};
|
|
3725
4296
|
const fromResolver = this.wireResolver?.(model) ?? this.wireResolver?.(entry?.name ?? "");
|
|
3726
4297
|
const target = fromCatalog ?? fromResolver;
|
|
3727
|
-
|
|
4298
|
+
/**
|
|
4299
|
+
* Last line of defence: the discovered row's own id.
|
|
4300
|
+
*
|
|
4301
|
+
* For models whose directory id IS the gateway `config_name` the merge
|
|
4302
|
+
* deliberately emits no `wireConfigName` (there is nothing to map), so a
|
|
4303
|
+
* requested id that differs only by case has neither mapping source —
|
|
4304
|
+
* and the row we just matched case-blindly is exactly the canonical
|
|
4305
|
+
* spelling. Sending it is what a correct mapping would have produced.
|
|
4306
|
+
*
|
|
4307
|
+
* Deliberately NOT derived from the row's `name`: the rosters spell most
|
|
4308
|
+
* ids in the opposite case of their display names (`glm-5.3` vs
|
|
4309
|
+
* `GLM-5.3`) and the gateway accepts the LOWERCASE id for those — a
|
|
4310
|
+
* name-derived rewrite answered `4001` for 4 of 5 probed models.
|
|
4311
|
+
*/
|
|
4312
|
+
const wireModel = target?.configName ?? entry?.id ?? model;
|
|
3728
4313
|
const wireFunction = target?.function ?? entry?.wireFunction;
|
|
3729
4314
|
if (wireModel !== input["model"]) input["model"] = wireModel;
|
|
3730
4315
|
if (wireFunction !== void 0 && input["function"] !== wireFunction) input["function"] = wireFunction;
|
|
@@ -4630,13 +5215,37 @@ async function readProbeOutput(response) {
|
|
|
4630
5215
|
};
|
|
4631
5216
|
}
|
|
4632
5217
|
}
|
|
4633
|
-
/**
|
|
4634
|
-
|
|
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";
|
|
4635
5244
|
return JSON.stringify({
|
|
4636
5245
|
model,
|
|
4637
5246
|
messages: [{
|
|
4638
5247
|
role: "user",
|
|
4639
|
-
content
|
|
5248
|
+
content
|
|
4640
5249
|
}]
|
|
4641
5250
|
});
|
|
4642
5251
|
}
|
|
@@ -4645,11 +5254,11 @@ function probeBody(model) {
|
|
|
4645
5254
|
* recorded verdict describes the user's real path (same wire resolution, same
|
|
4646
5255
|
* function stamping, same error translation).
|
|
4647
5256
|
*/
|
|
4648
|
-
async function probeOneModel(chat, id, timeoutMs = TRAE_PROBE_TIMEOUT_MS) {
|
|
5257
|
+
async function probeOneModel(chat, id, timeoutMs = TRAE_PROBE_TIMEOUT_MS, inputTokens = 0) {
|
|
4649
5258
|
const controller = new AbortController();
|
|
4650
5259
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
4651
5260
|
try {
|
|
4652
|
-
const result = await chat(probeBody(id), controller.signal);
|
|
5261
|
+
const result = await chat(probeBody(id, inputTokens), controller.signal);
|
|
4653
5262
|
if (!result.ok) return {
|
|
4654
5263
|
id,
|
|
4655
5264
|
...classifyProbeResult(result, false)
|
|
@@ -4969,6 +5578,322 @@ var TraeUsageClient = class {
|
|
|
4969
5578
|
}
|
|
4970
5579
|
};
|
|
4971
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
|
|
4972
5897
|
//#region src/transfer.ts
|
|
4973
5898
|
/**
|
|
4974
5899
|
* Account transfer: the export / import file format shared with sibling
|
|
@@ -5241,7 +6166,7 @@ function mergeTransferRecords(records, indexes, region, knownRegionOfCredential
|
|
|
5241
6166
|
* matched on the code rather than on the HTTP status: read as a transport
|
|
5242
6167
|
* failure it looks like the plugin is broken, when the day is simply spent.
|
|
5243
6168
|
*/
|
|
5244
|
-
const CHECKIN_DEVICE_ALREADY_CLAIMED = 9095;
|
|
6169
|
+
const CHECKIN_DEVICE_ALREADY_CLAIMED$1 = 9095;
|
|
5245
6170
|
/** Redact token-like content before it crosses to the browser. */
|
|
5246
6171
|
function safeMessage(error) {
|
|
5247
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);
|
|
@@ -5381,7 +6306,8 @@ function toCredits(snapshot, nowMs = Date.now()) {
|
|
|
5381
6306
|
displayDesc: pack.displayDesc,
|
|
5382
6307
|
remain: credit(Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))),
|
|
5383
6308
|
size: pack.creditsLimit ?? 0,
|
|
5384
|
-
...usableExpiry(pack.endTimeMs) ? { expiresAtMs: pack.endTimeMs } : {}
|
|
6309
|
+
...usableExpiry(pack.endTimeMs) ? { expiresAtMs: pack.endTimeMs } : {},
|
|
6310
|
+
...pack.availableEndpoint === void 0 ? {} : { endpoint: pack.availableEndpoint }
|
|
5385
6311
|
}));
|
|
5386
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);
|
|
5387
6313
|
return {
|
|
@@ -5469,16 +6395,26 @@ function toCheckin(status) {
|
|
|
5469
6395
|
};
|
|
5470
6396
|
}
|
|
5471
6397
|
/**
|
|
5472
|
-
* Assemble one region's card document. `
|
|
5473
|
-
*
|
|
6398
|
+
* Assemble one region's card document. `panel` is the tab the card is on; the
|
|
6399
|
+
* panel-scoped store already answers with only that region's accounts, so the
|
|
5474
6400
|
* document's model slots and account list are that region's by construction.
|
|
5475
6401
|
* Sign-in state is read-only; credit is a live billing answer whose failure
|
|
5476
6402
|
* degrades to `creditsError` rather than failing the whole document.
|
|
5477
6403
|
*/
|
|
5478
|
-
async function traeWebUsage(deps,
|
|
5479
|
-
const store = deps.store(
|
|
5480
|
-
const enabled = deps.regionEnabled(
|
|
5481
|
-
|
|
6404
|
+
async function traeWebUsage(deps, panel) {
|
|
6405
|
+
const store = deps.store(panel);
|
|
6406
|
+
const enabled = deps.regionEnabled(panel);
|
|
6407
|
+
/**
|
|
6408
|
+
* Accounts this panel may actually bill.
|
|
6409
|
+
*
|
|
6410
|
+
* Filtered ONCE, at the source, because this list is spliced into every
|
|
6411
|
+
* response shape below (`signed-out`, `signed-in`, the credits route). The
|
|
6412
|
+
* enterprise pool can only bill a tenant account, and the tenant gateway does
|
|
6413
|
+
* not make that obvious: it answers a personal token's DIRECTORY read in
|
|
6414
|
+
* full and only refuses its chat (`4019`). Listing a personal account there
|
|
6415
|
+
* would offer a binding that cannot work.
|
|
6416
|
+
*/
|
|
6417
|
+
const accounts = (await store.accounts()).filter((account) => panel !== "enterprise" || account.enterprise);
|
|
5482
6418
|
if ((await store.status()).state !== "signed-in") {
|
|
5483
6419
|
const { failures } = await store.diagnose();
|
|
5484
6420
|
return {
|
|
@@ -5507,45 +6443,66 @@ async function traeWebUsage(deps, region) {
|
|
|
5507
6443
|
}
|
|
5508
6444
|
let appLogin;
|
|
5509
6445
|
try {
|
|
5510
|
-
appLogin = await deps.liveLogin?.(
|
|
6446
|
+
appLogin = await deps.liveLogin?.(panel);
|
|
5511
6447
|
} catch {}
|
|
5512
6448
|
const account = {
|
|
5513
6449
|
accountId: accounts.find((item) => item.selected)?.id ?? "",
|
|
5514
6450
|
accountName: credential.accountName ?? credential.userId,
|
|
5515
6451
|
tokenExpiresAtMs: credential.expiresAtMs,
|
|
5516
6452
|
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
|
|
5517
|
-
region,
|
|
6453
|
+
region: regionOfPanel(panel),
|
|
5518
6454
|
enabled,
|
|
5519
6455
|
accounts,
|
|
5520
6456
|
...appLogin === void 0 ? {} : { appLogin },
|
|
5521
|
-
models: deps.displayModels(
|
|
6457
|
+
models: deps.displayModels(panel).map((model) => ({
|
|
5522
6458
|
...model,
|
|
5523
6459
|
...model.input === void 0 ? {} : { input: [...model.input] }
|
|
5524
6460
|
})),
|
|
5525
|
-
enabledModelIds: [...deps.enabledModelIds(
|
|
5526
|
-
...deps.rawDiagnostic === void 0 ? {} : { rawChat: deps.rawDiagnostic(
|
|
6461
|
+
enabledModelIds: [...deps.enabledModelIds(panel)],
|
|
6462
|
+
...deps.rawDiagnostic === void 0 ? {} : { rawChat: deps.rawDiagnostic(panel) }
|
|
5527
6463
|
};
|
|
5528
|
-
const
|
|
5529
|
-
|
|
6464
|
+
const poolSection = await poolSectionOf(deps, panel);
|
|
6465
|
+
const client = deps.client(panel);
|
|
6466
|
+
if (regionOfPanel(panel) === "ai") try {
|
|
5530
6467
|
const payStatus = await client.payStatus();
|
|
5531
6468
|
return {
|
|
5532
6469
|
status: "signed-in",
|
|
5533
6470
|
...account,
|
|
6471
|
+
...poolSection,
|
|
5534
6472
|
payStatus
|
|
5535
6473
|
};
|
|
5536
6474
|
} catch (error) {
|
|
5537
6475
|
return {
|
|
5538
6476
|
status: "signed-in",
|
|
5539
6477
|
...account,
|
|
6478
|
+
...poolSection,
|
|
5540
6479
|
payStatusError: safeMessage(error)
|
|
5541
6480
|
};
|
|
5542
6481
|
}
|
|
6482
|
+
/**
|
|
6483
|
+
* The enterprise panel shows NO credit figure, deliberately.
|
|
6484
|
+
*
|
|
6485
|
+
* `client.snapshot()` reads the CONSUMER packs, and that is the personal
|
|
6486
|
+
* balance — not what a tenant turn spends. There is no enterprise counterpart
|
|
6487
|
+
* to read: every tenant quota endpoint answers 404 (probed 2026-10-17), and
|
|
6488
|
+
* the tenant console at console.enterprise.trae.cn is the only place the
|
|
6489
|
+
* enterprise balance exists. Showing the personal number under an enterprise
|
|
6490
|
+
* heading would be actively misleading, so the panel reports the absence
|
|
6491
|
+
* explicitly and the card explains where the real figure lives.
|
|
6492
|
+
*/
|
|
6493
|
+
if (panel === "enterprise") return {
|
|
6494
|
+
status: "signed-in",
|
|
6495
|
+
...account,
|
|
6496
|
+
...poolSection,
|
|
6497
|
+
creditsUnavailable: "enterprise"
|
|
6498
|
+
};
|
|
5543
6499
|
const [snapshotResult, checkinResult] = await Promise.allSettled([client.snapshot(), client.checkinStatus()]);
|
|
5544
6500
|
const credits = snapshotResult.status === "fulfilled" ? toCredits(snapshotResult.value) : void 0;
|
|
5545
|
-
const alternatives = credits !== void 0 && credits.workAvailable <= 0 && credits.generalAvailable <= 0 ? await creditAlternatives(deps,
|
|
6501
|
+
const alternatives = credits !== void 0 && credits.workAvailable <= 0 && credits.generalAvailable <= 0 ? await creditAlternatives(deps, panel, accounts) : void 0;
|
|
5546
6502
|
return {
|
|
5547
6503
|
status: "signed-in",
|
|
5548
6504
|
...account,
|
|
6505
|
+
...poolSection,
|
|
5549
6506
|
...credits === void 0 ? { creditsError: snapshotResult.status === "rejected" ? safeMessage(snapshotResult.reason) : "unknown" } : { credits },
|
|
5550
6507
|
...checkinResult.status === "fulfilled" ? { checkin: toCheckin(checkinResult.value) } : { checkinError: safeMessage(checkinResult.reason) },
|
|
5551
6508
|
...alternatives === void 0 ? {} : { alternatives }
|
|
@@ -5563,13 +6520,13 @@ async function traeWebUsage(deps, region) {
|
|
|
5563
6520
|
* problem to explain. The list is capped so a machine with many stale
|
|
5564
6521
|
* credentials cannot turn one status read into a burst of upstream calls.
|
|
5565
6522
|
*/
|
|
5566
|
-
async function creditAlternatives(deps,
|
|
6523
|
+
async function creditAlternatives(deps, panel, accounts) {
|
|
5567
6524
|
if (deps.creditsOfAccount === void 0) return void 0;
|
|
5568
|
-
const candidates = accounts.filter((account) => !account.selected && account.region ===
|
|
6525
|
+
const candidates = accounts.filter((account) => !account.selected && account.region === regionOfPanel(panel)).filter((account) => panel !== "enterprise" || account.enterprise).slice(0, TRAE_CREDIT_ALTERNATIVE_LIMIT);
|
|
5569
6526
|
if (candidates.length === 0) return void 0;
|
|
5570
6527
|
const alternatives = (await Promise.all(candidates.map(async (account) => {
|
|
5571
6528
|
try {
|
|
5572
|
-
const credits = await deps.creditsOfAccount(
|
|
6529
|
+
const credits = await deps.creditsOfAccount(panel, account.id);
|
|
5573
6530
|
if (credits === void 0) return void 0;
|
|
5574
6531
|
if (credits.workAvailable <= 0 && credits.generalAvailable <= 0) return void 0;
|
|
5575
6532
|
return {
|
|
@@ -5586,11 +6543,190 @@ async function creditAlternatives(deps, region, accounts) {
|
|
|
5586
6543
|
return alternatives.length === 0 ? void 0 : alternatives;
|
|
5587
6544
|
}
|
|
5588
6545
|
/**
|
|
5589
|
-
* The
|
|
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
|
+
/**
|
|
6725
|
+
* The panel a request addresses, or a 400 answer. Absent parameter means the
|
|
5590
6726
|
* domestic tab; an unknown value is refused rather than guessed.
|
|
5591
6727
|
*/
|
|
5592
6728
|
/**
|
|
5593
|
-
* The general balance of every account in one
|
|
6729
|
+
* The general balance of every account in one panel.
|
|
5594
6730
|
*
|
|
5595
6731
|
* One upstream read per account — the same call `creditsOfAccount` already
|
|
5596
6732
|
* makes for the exhausted-balance alternatives, widened to every account and to
|
|
@@ -5601,16 +6737,16 @@ async function creditAlternatives(deps, region, accounts) {
|
|
|
5601
6737
|
* figure: the table lists how many accounts exist even when one cannot be
|
|
5602
6738
|
* quoted, rather than dropping it and making the switch unavailable.
|
|
5603
6739
|
*/
|
|
5604
|
-
async function traeAccountCredits(deps,
|
|
5605
|
-
const accounts = (await deps.store(
|
|
6740
|
+
async function traeAccountCredits(deps, panel) {
|
|
6741
|
+
const accounts = (await deps.store(panel).accounts()).filter((account) => account.region === regionOfPanel(panel)).filter((account) => panel !== "enterprise" || account.enterprise);
|
|
5606
6742
|
if (accounts.length === 0) return [];
|
|
5607
6743
|
return Promise.all(accounts.map(async (account) => {
|
|
5608
6744
|
let generalAvailable;
|
|
5609
6745
|
if (account.selected) try {
|
|
5610
|
-
generalAvailable = toCredits(await deps.client(
|
|
6746
|
+
generalAvailable = toCredits(await deps.client(panel).snapshot()).generalAvailable;
|
|
5611
6747
|
} catch {}
|
|
5612
6748
|
else if (deps.creditsOfAccount !== void 0) try {
|
|
5613
|
-
generalAvailable = (await deps.creditsOfAccount(
|
|
6749
|
+
generalAvailable = (await deps.creditsOfAccount(panel, account.id))?.generalAvailable;
|
|
5614
6750
|
} catch {}
|
|
5615
6751
|
return {
|
|
5616
6752
|
id: account.id,
|
|
@@ -5621,12 +6757,12 @@ async function traeAccountCredits(deps, region) {
|
|
|
5621
6757
|
}));
|
|
5622
6758
|
}
|
|
5623
6759
|
function requestRegion(req, res) {
|
|
5624
|
-
const
|
|
5625
|
-
if (
|
|
6760
|
+
const panel = regionOfTraeStatusUrl(req.url ?? "/");
|
|
6761
|
+
if (panel === void 0) {
|
|
5626
6762
|
json(res, 400, { error: "unknown region" });
|
|
5627
6763
|
return;
|
|
5628
6764
|
}
|
|
5629
|
-
return
|
|
6765
|
+
return panel;
|
|
5630
6766
|
}
|
|
5631
6767
|
/** Mount the GET usage route on an optional webServer context. */
|
|
5632
6768
|
function registerTraeUsageRoute(ctx, deps) {
|
|
@@ -5643,17 +6779,17 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5643
6779
|
json(res, 403, { error: "origin-not-trusted" });
|
|
5644
6780
|
return;
|
|
5645
6781
|
}
|
|
5646
|
-
const
|
|
5647
|
-
if (
|
|
6782
|
+
const panel = requestRegion(req, res);
|
|
6783
|
+
if (panel === void 0) return;
|
|
5648
6784
|
try {
|
|
5649
|
-
json(res, 200, await traeWebUsage(deps,
|
|
6785
|
+
json(res, 200, await traeWebUsage(deps, panel));
|
|
5650
6786
|
} catch (error) {
|
|
5651
6787
|
json(res, 500, { error: safeMessage(error) });
|
|
5652
6788
|
}
|
|
5653
6789
|
}
|
|
5654
6790
|
});
|
|
5655
6791
|
/**
|
|
5656
|
-
* General balance of every account in the
|
|
6792
|
+
* General balance of every account in the panel — read only when the
|
|
5657
6793
|
* composer panel is opened. One upstream read per account, paid once per
|
|
5658
6794
|
* open rather than on the 5-minute cycle (see
|
|
5659
6795
|
* {@link TRAE_ACCOUNT_CREDITS_PATH}).
|
|
@@ -5664,10 +6800,10 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5664
6800
|
handler: async (req, res) => {
|
|
5665
6801
|
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
5666
6802
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5667
|
-
const
|
|
5668
|
-
if (
|
|
6803
|
+
const panel = requestRegion(req, res);
|
|
6804
|
+
if (panel === void 0) return;
|
|
5669
6805
|
try {
|
|
5670
|
-
json(res, 200, { accounts: await traeAccountCredits(deps,
|
|
6806
|
+
json(res, 200, { accounts: await traeAccountCredits(deps, panel) });
|
|
5671
6807
|
} catch (error) {
|
|
5672
6808
|
json(res, 500, { error: safeMessage(error) });
|
|
5673
6809
|
}
|
|
@@ -5687,12 +6823,12 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5687
6823
|
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
5688
6824
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5689
6825
|
if (deps.accountStateOf === void 0) return json(res, 503, { error: "account status unavailable" });
|
|
5690
|
-
const
|
|
5691
|
-
if (
|
|
6826
|
+
const panel = requestRegion(req, res);
|
|
6827
|
+
if (panel === void 0) return;
|
|
5692
6828
|
const accountId = new URL(req.url ?? "/", "http://localhost").searchParams.get("accountId");
|
|
5693
6829
|
if (accountId === null || accountId.trim() === "") return json(res, 400, { error: "missing accountId" });
|
|
5694
6830
|
try {
|
|
5695
|
-
const state = await deps.accountStateOf(
|
|
6831
|
+
const state = await deps.accountStateOf(panel, accountId);
|
|
5696
6832
|
if (state === void 0) return json(res, 404, { error: "unknown account" });
|
|
5697
6833
|
json(res, 200, state);
|
|
5698
6834
|
} catch (error) {
|
|
@@ -5706,10 +6842,10 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5706
6842
|
handler: async (req, res) => {
|
|
5707
6843
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
5708
6844
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5709
|
-
const
|
|
5710
|
-
if (
|
|
6845
|
+
const panel = requestRegion(req, res);
|
|
6846
|
+
if (panel === void 0) return;
|
|
5711
6847
|
try {
|
|
5712
|
-
json(res, 200, { accounts: await deps.store(
|
|
6848
|
+
json(res, 200, { accounts: (await deps.store(panel).accounts()).filter((account) => panel !== "enterprise" || account.enterprise) });
|
|
5713
6849
|
} catch (error) {
|
|
5714
6850
|
json(res, 500, { error: safeMessage(error) });
|
|
5715
6851
|
}
|
|
@@ -5730,15 +6866,15 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5730
6866
|
handler: async (req, res) => {
|
|
5731
6867
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
5732
6868
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5733
|
-
const
|
|
5734
|
-
if (
|
|
6869
|
+
const panel = requestRegion(req, res);
|
|
6870
|
+
if (panel === void 0) return;
|
|
5735
6871
|
const action = transferActionOf(req.url ?? "");
|
|
5736
6872
|
if (action === void 0) return json(res, 400, { error: "unknown transfer action" });
|
|
5737
6873
|
try {
|
|
5738
6874
|
const body = await readTransferBody(req);
|
|
5739
6875
|
if (action === "export") {
|
|
5740
6876
|
const wanted = new Set(Array.isArray(body["accountIds"]) ? body["accountIds"].filter((id) => typeof id === "string") : []);
|
|
5741
|
-
const store = deps.store(
|
|
6877
|
+
const store = deps.store(panel);
|
|
5742
6878
|
const records = [];
|
|
5743
6879
|
for (const account of await store.accounts()) {
|
|
5744
6880
|
if (!wanted.has(account.id)) continue;
|
|
@@ -5765,8 +6901,8 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5765
6901
|
const text = typeof body["text"] === "string" ? body["text"] : "";
|
|
5766
6902
|
if (action === "preview") return json(res, 200, previewTransferFile(text));
|
|
5767
6903
|
const indexes = Array.isArray(body["indexes"]) ? body["indexes"].filter((index) => typeof index === "number" && Number.isInteger(index)) : [];
|
|
5768
|
-
const { changes, imported, skipped } = mergeTransferRecords(parseTransferFile(text), indexes,
|
|
5769
|
-
if (changes.size > 0) await deps.store(
|
|
6904
|
+
const { changes, imported, skipped } = mergeTransferRecords(parseTransferFile(text), indexes, regionOfPanel(panel));
|
|
6905
|
+
if (changes.size > 0) await deps.store(panel).importAccounts([...changes.values()]);
|
|
5770
6906
|
json(res, 200, {
|
|
5771
6907
|
imported,
|
|
5772
6908
|
skipped
|
|
@@ -5783,10 +6919,10 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5783
6919
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
5784
6920
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5785
6921
|
if (deps.discoverModels === void 0) return json(res, 503, { error: "model refresh unavailable" });
|
|
5786
|
-
const
|
|
5787
|
-
if (
|
|
6922
|
+
const panel = requestRegion(req, res);
|
|
6923
|
+
if (panel === void 0) return;
|
|
5788
6924
|
try {
|
|
5789
|
-
json(res, 200, { models: await deps.discoverModels(
|
|
6925
|
+
json(res, 200, { models: await deps.discoverModels(panel) });
|
|
5790
6926
|
} catch (error) {
|
|
5791
6927
|
json(res, 500, { error: safeMessage(error) });
|
|
5792
6928
|
}
|
|
@@ -5809,8 +6945,8 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5809
6945
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
5810
6946
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5811
6947
|
if (deps.probeModels === void 0) return json(res, 503, { error: "model test unavailable" });
|
|
5812
|
-
const
|
|
5813
|
-
if (
|
|
6948
|
+
const panel = requestRegion(req, res);
|
|
6949
|
+
if (panel === void 0) return;
|
|
5814
6950
|
let ids;
|
|
5815
6951
|
try {
|
|
5816
6952
|
ids = await readModelTestBody(req);
|
|
@@ -5820,7 +6956,7 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5820
6956
|
if (ids.length === 0) return json(res, 400, { error: "no models to test" });
|
|
5821
6957
|
if (ids.length > 64) return json(res, 400, { error: `too many models (limit ${String(64)})` });
|
|
5822
6958
|
try {
|
|
5823
|
-
json(res, 200, await deps.probeModels(
|
|
6959
|
+
json(res, 200, await deps.probeModels(panel, ids));
|
|
5824
6960
|
} catch (error) {
|
|
5825
6961
|
json(res, 500, { error: safeMessage(error) });
|
|
5826
6962
|
}
|
|
@@ -5852,9 +6988,9 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5852
6988
|
handler: async (req, res) => {
|
|
5853
6989
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
5854
6990
|
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
5855
|
-
const
|
|
5856
|
-
if (
|
|
5857
|
-
if (
|
|
6991
|
+
const panel = requestRegion(req, res);
|
|
6992
|
+
if (panel === void 0) return;
|
|
6993
|
+
if (regionOfPanel(panel) !== "cn") return json(res, 404, { error: "check-in is not available for the international region" });
|
|
5858
6994
|
try {
|
|
5859
6995
|
let accountId;
|
|
5860
6996
|
try {
|
|
@@ -5864,10 +7000,10 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5864
7000
|
}
|
|
5865
7001
|
if (accountId !== void 0) {
|
|
5866
7002
|
if (deps.claimCheckinOf === void 0) return json(res, 503, { error: "check-in by account unavailable" });
|
|
5867
|
-
const outcome = await deps.claimCheckinOf(
|
|
7003
|
+
const outcome = await deps.claimCheckinOf(panel, accountId);
|
|
5868
7004
|
if (outcome === void 0) return json(res, 404, { error: "unknown account" });
|
|
5869
7005
|
const { claim, checkin: after } = outcome;
|
|
5870
|
-
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED;
|
|
7006
|
+
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED$1;
|
|
5871
7007
|
return json(res, 200, {
|
|
5872
7008
|
claimed: claim.claimed,
|
|
5873
7009
|
alreadyCheckedIn: after.checkedIn,
|
|
@@ -5877,7 +7013,7 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5877
7013
|
checkin: toCheckin(after)
|
|
5878
7014
|
});
|
|
5879
7015
|
}
|
|
5880
|
-
const client = deps.client(
|
|
7016
|
+
const client = deps.client(panel);
|
|
5881
7017
|
const current = await client.checkinStatus();
|
|
5882
7018
|
if (!current.enabled) return json(res, 409, { error: "check-in is not enabled for this account" });
|
|
5883
7019
|
if (current.checkedIn) return json(res, 200, {
|
|
@@ -5890,13 +7026,13 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5890
7026
|
claimed: false,
|
|
5891
7027
|
alreadyCheckedIn: false,
|
|
5892
7028
|
deviceCheckedIn: true,
|
|
5893
|
-
code: CHECKIN_DEVICE_ALREADY_CLAIMED,
|
|
7029
|
+
code: CHECKIN_DEVICE_ALREADY_CLAIMED$1,
|
|
5894
7030
|
message: "this device already used today's check-in",
|
|
5895
7031
|
checkin: toCheckin(current)
|
|
5896
7032
|
});
|
|
5897
7033
|
const claim = await client.claimCheckin();
|
|
5898
7034
|
const checkin = await client.checkinStatus();
|
|
5899
|
-
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED;
|
|
7035
|
+
const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED$1;
|
|
5900
7036
|
json(res, 200, {
|
|
5901
7037
|
claimed: claim.claimed,
|
|
5902
7038
|
alreadyCheckedIn: false,
|
|
@@ -5910,7 +7046,131 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5910
7046
|
}
|
|
5911
7047
|
}
|
|
5912
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
|
+
});
|
|
5913
7172
|
return () => {
|
|
7173
|
+
disposePool();
|
|
5914
7174
|
disposeCheckin();
|
|
5915
7175
|
disposeRefresh();
|
|
5916
7176
|
disposeTransfer();
|
|
@@ -5923,6 +7183,538 @@ function registerTraeUsageRoute(ctx, deps) {
|
|
|
5923
7183
|
}, "dsh-connect-trae: Web usage route");
|
|
5924
7184
|
}
|
|
5925
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
|
|
5926
7718
|
//#region src/model-config.ts
|
|
5927
7719
|
/**
|
|
5928
7720
|
* Parse a secret-free CustomModel debug line emitted by Trae. Optional values
|
|
@@ -6146,18 +7938,24 @@ function settingsNamespaceOf(ctx) {
|
|
|
6146
7938
|
return typeof id === "string" && id !== "" ? id : TRAE_SETTINGS_NS;
|
|
6147
7939
|
}
|
|
6148
7940
|
/**
|
|
6149
|
-
* One
|
|
7941
|
+
* One panel's saved state. A config written before the region split has only
|
|
6150
7942
|
* the flat fields: those were always captured from the CN endpoint (the
|
|
6151
7943
|
* plugin had no international support), so they are read as the CN state and
|
|
6152
7944
|
* only when no explicit CN slot exists. The ai region never inherits them —
|
|
6153
7945
|
* that inheritance is exactly the bug where a stale CN directory would be
|
|
6154
7946
|
* intersected with the international catalog and silently drop the user's
|
|
6155
7947
|
* picks (the same failure workbuddy fixed with its region split).
|
|
7948
|
+
*
|
|
7949
|
+
* Keyed by PANEL (`cn` | `enterprise` | `ai`), because each panel carries its
|
|
7950
|
+
* own switch, account binding and model selection. `enterprise` has no legacy
|
|
7951
|
+
* equivalent: it starts from an EMPTY state (its own directory, its own model
|
|
7952
|
+
* picks) and never inherits CN's, so switching it on cannot silently activate
|
|
7953
|
+
* a model list the user never chose for the tenant pool.
|
|
6156
7954
|
*/
|
|
6157
|
-
function regionStateOf(config,
|
|
6158
|
-
const stored = unwrapVolatile(unwrapVolatile(config.regions)?.[
|
|
7955
|
+
function regionStateOf(config, panel) {
|
|
7956
|
+
const stored = unwrapVolatile(unwrapVolatile(config.regions)?.[panel]);
|
|
6159
7957
|
if (stored !== void 0) return stored;
|
|
6160
|
-
if (
|
|
7958
|
+
if (panel !== "cn") return {};
|
|
6161
7959
|
return {
|
|
6162
7960
|
...config.lastCatalog === void 0 ? {} : { lastCatalog: config.lastCatalog },
|
|
6163
7961
|
...config.enabledModelIds === void 0 ? {} : { enabledModelIds: config.enabledModelIds },
|
|
@@ -6166,15 +7964,23 @@ function regionStateOf(config, region) {
|
|
|
6166
7964
|
};
|
|
6167
7965
|
}
|
|
6168
7966
|
/**
|
|
6169
|
-
* Whether one
|
|
7967
|
+
* Whether one panel's provider is switched on (issue #11). Opt-out semantics:
|
|
6170
7968
|
* only an explicit `false` disables it, so every config written before this
|
|
6171
7969
|
* switch existed — including the pre-region-split flat fields, which never
|
|
6172
7970
|
* carry `enabled` — keeps both providers running exactly as before. The card
|
|
6173
7971
|
* reads the same rule through `regionEnabledOf`, so the two halves can never
|
|
6174
7972
|
* disagree about a region's state.
|
|
7973
|
+
*
|
|
7974
|
+
* The ENTERPRISE panel is the one deliberate exception: it defaults OFF. A
|
|
7975
|
+
* consumer-only account (`tenant 7o2d894p7dr0o4`) cannot use that pool at all,
|
|
7976
|
+
* so opting every existing user into a second CN provider would put a route in
|
|
7977
|
+
* their model picker that fails on first use. It is switched on from its card
|
|
7978
|
+
* tab, by someone whose account actually belongs to a tenant.
|
|
6175
7979
|
*/
|
|
6176
|
-
function regionEnabled(config,
|
|
6177
|
-
|
|
7980
|
+
function regionEnabled(config, panel) {
|
|
7981
|
+
const stored = regionStateOf(config, panel).enabled;
|
|
7982
|
+
if (stored !== void 0) return stored !== false;
|
|
7983
|
+
return panel !== "enterprise";
|
|
6178
7984
|
}
|
|
6179
7985
|
/**
|
|
6180
7986
|
* WHERE one region's credit readout sits (learned from workbuddy #62).
|
|
@@ -6216,19 +8022,32 @@ const modelConfig = z.object({
|
|
|
6216
8022
|
input: z.array(z.union(["text", "image"])),
|
|
6217
8023
|
creditMultiplier: z.number(),
|
|
6218
8024
|
wireFunction: z.string(),
|
|
8025
|
+
wireConfigName: z.string(),
|
|
6219
8026
|
multimodal: z.boolean()
|
|
6220
8027
|
});
|
|
8028
|
+
/**
|
|
8029
|
+
* One panel's saved state. Panels are keyed `cn | enterprise | ai` — see
|
|
8030
|
+
* {@link TraePanel}; `enterprise` is a second CN provider with its own switch,
|
|
8031
|
+
* account binding and model selection.
|
|
8032
|
+
*/
|
|
6221
8033
|
const regionStateConfig = z.object({
|
|
6222
|
-
enabled: z.boolean().
|
|
8034
|
+
enabled: z.boolean().description("Whether this panel's provider is offered to DSH (false withdraws it entirely)"),
|
|
6223
8035
|
lastCatalog: z.array(modelConfig).default([]),
|
|
6224
8036
|
enabledModelIds: z.array(z.string()).default([]),
|
|
6225
8037
|
imageModelIds: z.array(z.string()).default([]),
|
|
6226
8038
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}),
|
|
6227
|
-
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({})
|
|
6228
8046
|
});
|
|
6229
8047
|
const accountSelectionConfig = z.object({
|
|
6230
8048
|
cn: z.string().description("Selected domestic (CN) account id (never a token)"),
|
|
6231
|
-
ai: z.string().description("Selected international account id (never a token)")
|
|
8049
|
+
ai: z.string().description("Selected international account id (never a token)"),
|
|
8050
|
+
enterprise: z.string().description("Selected account id for the CN enterprise pool (never a token)")
|
|
6232
8051
|
});
|
|
6233
8052
|
const Config = z.object({
|
|
6234
8053
|
authFile: asVolatile(z.string().description("Optional Trae storage.json path override")),
|
|
@@ -6242,8 +8061,8 @@ const Config = z.object({
|
|
|
6242
8061
|
accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
|
|
6243
8062
|
showPointsInMainUi: asVolatile(z.boolean().default(false).description("在输入框工具栏显示积分读数「Trae CN · <数值>」(仅当选中的模型属于本插件时出现,可点击查看账号与积分明细)")),
|
|
6244
8063
|
followAppLogin: asVolatile(z.boolean().default(false).description("跟随 Trae 桌面端当前登录账号换绑(打开后每次配置生效时重绑到 App 现在登录的账号)")),
|
|
6245
|
-
accounts: asVolatile(accountSelectionConfig.description("Per-
|
|
6246
|
-
regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-
|
|
8064
|
+
accounts: asVolatile(accountSelectionConfig.description("Per-panel account selections, keyed cn | enterprise | ai")),
|
|
8065
|
+
regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-panel model directory and selection, keyed cn | enterprise | ai")),
|
|
6247
8066
|
lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
|
|
6248
8067
|
enabledModelIds: z.array(z.string()).default([]).description("Deprecated: pre-region-split CN selection"),
|
|
6249
8068
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Deprecated: pre-region-split CN context budgets"),
|
|
@@ -6252,11 +8071,33 @@ const Config = z.object({
|
|
|
6252
8071
|
});
|
|
6253
8072
|
/** Every region, in card tab order. */
|
|
6254
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
|
+
};
|
|
6255
8096
|
function apply(ctx, config) {
|
|
6256
8097
|
const settingsNs = settingsNamespaceOf(ctx);
|
|
6257
8098
|
let current = () => config;
|
|
6258
|
-
const enabledSet = (value,
|
|
6259
|
-
const imageSet = (value,
|
|
8099
|
+
const enabledSet = (value, panel) => new Set(regionStateOf(value, panel).enabledModelIds ?? []);
|
|
8100
|
+
const imageSet = (value, panel) => new Set(regionStateOf(value, panel).imageModelIds ?? []);
|
|
6260
8101
|
/**
|
|
6261
8102
|
* Per-region wire state. Each region's discovery owns its own maps: the CN
|
|
6262
8103
|
* and international rosters overlap (and spell some ids differently), so a
|
|
@@ -6269,30 +8110,30 @@ function apply(ctx, config) {
|
|
|
6269
8110
|
* `lastCatalog` / `models` / `enabledModelIds` that still lists it.
|
|
6270
8111
|
*/
|
|
6271
8112
|
const wireState = {};
|
|
6272
|
-
for (const
|
|
8113
|
+
for (const panel of TRAE_PANELS) wireState[panel] = {
|
|
6273
8114
|
callableKeys: /* @__PURE__ */ new Set(),
|
|
6274
8115
|
resolved: false,
|
|
6275
8116
|
byId: /* @__PURE__ */ new Map(),
|
|
6276
8117
|
byName: /* @__PURE__ */ new Map()
|
|
6277
8118
|
};
|
|
6278
|
-
const dropDeadModels = (rows,
|
|
6279
|
-
const wire = wireState[
|
|
8119
|
+
const dropDeadModels = (rows, panel) => {
|
|
8120
|
+
const wire = wireState[panel];
|
|
6280
8121
|
if (!wire.resolved) return rows;
|
|
6281
8122
|
return rows.filter((model) => wire.callableKeys.has(model.id.trim().toLowerCase()) || wire.callableKeys.has(model.name.trim().toLowerCase()));
|
|
6282
8123
|
};
|
|
6283
|
-
const fallbackModels = (value,
|
|
6284
|
-
const derive = (value, raw,
|
|
6285
|
-
const selectedImages = imageSet(value,
|
|
6286
|
-
const derived = deriveCatalog(applyImageSelection(sanitizeCatalog(dropDeadModels(raw,
|
|
6287
|
-
return derived.length > 0 ? derived : fallbackModels(value,
|
|
8124
|
+
const fallbackModels = (value, panel) => applyImageSelection(fallbackModelsFor(regionOfPanel(panel)), imageSet(value, panel));
|
|
8125
|
+
const derive = (value, raw, panel) => {
|
|
8126
|
+
const selectedImages = imageSet(value, panel);
|
|
8127
|
+
const derived = deriveCatalog(applyImageSelection(sanitizeCatalog(dropDeadModels(raw, panel)), selectedImages), enabledSet(value, panel), regionStateOf(value, panel).contextBudgets ?? {});
|
|
8128
|
+
return derived.length > 0 ? derived : fallbackModels(value, panel);
|
|
6288
8129
|
};
|
|
6289
|
-
const configuredModels = (value,
|
|
6290
|
-
const state = regionStateOf(value,
|
|
6291
|
-
return state.lastCatalog?.length ? derive(value, state.lastCatalog,
|
|
8130
|
+
const configuredModels = (value, panel) => {
|
|
8131
|
+
const state = regionStateOf(value, panel);
|
|
8132
|
+
return state.lastCatalog?.length ? derive(value, state.lastCatalog, panel) : panel === "cn" && value.models?.length ? derive(value, value.models, panel) : fallbackModels(value, panel);
|
|
6292
8133
|
};
|
|
6293
|
-
const displayModels = (value,
|
|
6294
|
-
const state = regionStateOf(value,
|
|
6295
|
-
return state.lastCatalog?.length ? dropDeadModels(sanitizeCatalog(state.lastCatalog),
|
|
8134
|
+
const displayModels = (value, panel) => {
|
|
8135
|
+
const state = regionStateOf(value, panel);
|
|
8136
|
+
return state.lastCatalog?.length ? dropDeadModels(sanitizeCatalog(state.lastCatalog), panel) : panel === "cn" && value.models?.length ? dropDeadModels(sanitizeCatalog(value.models), panel) : fallbackModelsFor(regionOfPanel(panel));
|
|
6296
8137
|
};
|
|
6297
8138
|
/**
|
|
6298
8139
|
* Legacy migration for the pre-split single `accountId`: its region is
|
|
@@ -6302,9 +8143,20 @@ function apply(ctx, config) {
|
|
|
6302
8143
|
* inheriting a selection that belongs to the other side of the split.
|
|
6303
8144
|
*/
|
|
6304
8145
|
let legacyAccountRegion;
|
|
6305
|
-
|
|
6306
|
-
|
|
8146
|
+
/**
|
|
8147
|
+
* Which account a panel is bound to.
|
|
8148
|
+
*
|
|
8149
|
+
* Keyed by PANEL: the enterprise pool draws on the same CN credentials but
|
|
8150
|
+
* keeps its OWN binding, so pointing the consumer tab at a personal account
|
|
8151
|
+
* never moves the enterprise tab (and vice versa). A panel with no explicit
|
|
8152
|
+
* binding falls through to that REGION's default selection, which is the
|
|
8153
|
+
* store's own "preferred account for this region".
|
|
8154
|
+
*/
|
|
8155
|
+
const effectiveAccountFor = (panel, value) => {
|
|
8156
|
+
const explicit = unwrapVolatile(value.accounts)?.[panel];
|
|
6307
8157
|
if (explicit !== void 0) return explicit;
|
|
8158
|
+
const region = regionOfPanel(panel);
|
|
8159
|
+
if (panel === "enterprise") return void 0;
|
|
6308
8160
|
return legacyAccountRegion === region ? value.accountId : void 0;
|
|
6309
8161
|
};
|
|
6310
8162
|
/**
|
|
@@ -6326,10 +8178,29 @@ function apply(ctx, config) {
|
|
|
6326
8178
|
}
|
|
6327
8179
|
}
|
|
6328
8180
|
}
|
|
8181
|
+
/**
|
|
8182
|
+
* One stack per PANEL.
|
|
8183
|
+
*
|
|
8184
|
+
* `cn` and `enterprise` share a credential REGION but not a directory, a wire
|
|
8185
|
+
* map or a model selection: the two pools publish different rosters, so a
|
|
8186
|
+
* shared catalog would let one pool's answer filter the other's models. Each
|
|
8187
|
+
* panel therefore gets its own catalog, wire state, clients and shim routes,
|
|
8188
|
+
* and the credential store below is the only thing they have in common.
|
|
8189
|
+
*/
|
|
6329
8190
|
const stacks = {};
|
|
6330
|
-
for (const
|
|
8191
|
+
for (const panel of TRAE_PANELS) {
|
|
8192
|
+
const region = regionOfPanel(panel);
|
|
8193
|
+
/**
|
|
8194
|
+
* Whether tenant (enterprise) accounts are also offered by the CONSUMER
|
|
8195
|
+
* panels.
|
|
8196
|
+
*
|
|
8197
|
+
* Off by default, so a published build offers an enterprise login only
|
|
8198
|
+
* under the enterprise panel. Opt-in per machine, and intentionally not
|
|
8199
|
+
* surfaced in user-facing documentation.
|
|
8200
|
+
*/
|
|
8201
|
+
const tenantLocal = process.env["DSH_TRAE_TENANT_IN_CONSUMER"] === "1";
|
|
6331
8202
|
const catalog = new TraeCatalog(region);
|
|
6332
|
-
const wire = wireState[
|
|
8203
|
+
const wire = wireState[panel];
|
|
6333
8204
|
/**
|
|
6334
8205
|
* Best-effort device identity for this region's installation. Prefer the
|
|
6335
8206
|
* selected credential's own edition so an international account reads its
|
|
@@ -6382,6 +8253,7 @@ function apply(ctx, config) {
|
|
|
6382
8253
|
};
|
|
6383
8254
|
const store = new TraeCredentialStore({
|
|
6384
8255
|
region,
|
|
8256
|
+
...panel === "enterprise" ? { requireTenant: true } : { hideTenantAccounts: !tenantLocal },
|
|
6385
8257
|
...unwrapVolatile(config.authFile) === void 0 ? {} : { storagePath: unwrapVolatile(config.authFile) },
|
|
6386
8258
|
edition: unwrapVolatile(config.edition) ?? "auto",
|
|
6387
8259
|
refresh: async (credential) => refreshTraeCredential(credential, void 0, await refreshDeviceSafe())
|
|
@@ -6389,19 +8261,66 @@ function apply(ctx, config) {
|
|
|
6389
8261
|
const solo = new TraeSoloUpstreamClient({
|
|
6390
8262
|
credential: () => store.resolve(),
|
|
6391
8263
|
identity,
|
|
8264
|
+
...panel === "enterprise" ? { route: "enterprise" } : {},
|
|
6392
8265
|
log: (message, detail) => ctx.logger.warn(message, detail)
|
|
6393
8266
|
});
|
|
6394
8267
|
const remoteCatalog = new TraeSoloRemoteCatalogClient({ credential: () => store.resolve() });
|
|
6395
8268
|
const wireResolver = (displayId) => wire.byId.get(displayId) ?? wire.byName.get(displayId.trim().toLowerCase());
|
|
6396
|
-
|
|
6397
|
-
|
|
8269
|
+
/**
|
|
8270
|
+
* One client + bridge per provider id.
|
|
8271
|
+
*
|
|
8272
|
+
* `routeOfTraeProvider` pins each to its billing pool: the consumer ids get
|
|
8273
|
+
* the region gateway exactly as before, `trae-enterprise` gets the tenant
|
|
8274
|
+
* gateway and stamps the `session_id` it requires. The discovery client
|
|
8275
|
+
* below stays UNROUTED on purpose — it owns the roster/probe path and, for
|
|
8276
|
+
* a tenant credential, reads both pools so the shared catalogue keeps every
|
|
8277
|
+
* model either one serves.
|
|
8278
|
+
*/
|
|
8279
|
+
/**
|
|
8280
|
+
* Latest successful discovery for this panel, mutated by discoverModels
|
|
8281
|
+
* and READ by applySelection (via the stack): a settings write rebuilds
|
|
8282
|
+
* the catalog from saved rows that may predate wireConfigName, and the
|
|
8283
|
+
* merge below is what keeps the case mapping alive across it.
|
|
8284
|
+
*/
|
|
8285
|
+
const latestDiscovery = [];
|
|
8286
|
+
const routeClients = {};
|
|
8287
|
+
const shimRoutes = [];
|
|
8288
|
+
for (const provider of [providerOfPanel(panel)]) {
|
|
8289
|
+
const route = routeOfTraeProvider(provider) ?? "solo";
|
|
8290
|
+
const client = new TraeSoloUpstreamClient({
|
|
8291
|
+
credential: () => store.resolve(),
|
|
8292
|
+
identity,
|
|
8293
|
+
route,
|
|
8294
|
+
log: (message, detail) => ctx.logger.warn(message, detail)
|
|
8295
|
+
});
|
|
8296
|
+
const delegating = new TraeDelegatingUpstreamClient(new TraeSoloBridge(client, catalog, wireResolver));
|
|
8297
|
+
routeClients[provider] = {
|
|
8298
|
+
provider,
|
|
8299
|
+
route,
|
|
8300
|
+
client,
|
|
8301
|
+
upstream: delegating,
|
|
8302
|
+
secret: ""
|
|
8303
|
+
};
|
|
8304
|
+
shimRoutes.push({
|
|
8305
|
+
label: route,
|
|
8306
|
+
client: delegating,
|
|
8307
|
+
pool: poolShimHooksOf(panel)
|
|
8308
|
+
});
|
|
8309
|
+
}
|
|
6398
8310
|
const shim = createTraeShim({
|
|
6399
8311
|
catalog,
|
|
6400
|
-
|
|
8312
|
+
routes: shimRoutes,
|
|
6401
8313
|
logger: ctx.logger
|
|
6402
8314
|
});
|
|
8315
|
+
for (const entry of Object.values(routeClients)) entry.secret = shim.tokenOf(entry.route);
|
|
8316
|
+
/**
|
|
8317
|
+
* The region's PRIMARY provider plumbing, used by region-wide paths that
|
|
8318
|
+
* are not pool-specific: model probing, Raw-Chat status, and the stack's
|
|
8319
|
+
* `delegating` handle. Per-provider turns never go through this.
|
|
8320
|
+
*/
|
|
8321
|
+
const primary = routeClients[providerOfPanel(panel)];
|
|
6403
8322
|
let rawDiagnostic = () => ({ state: "disabled" });
|
|
6404
|
-
if (
|
|
8323
|
+
if (panel === "cn") (async () => {
|
|
6405
8324
|
try {
|
|
6406
8325
|
const resolved = await resolveTraeRawRuntime(store, "qwen-3.7-plus");
|
|
6407
8326
|
const gateway = createTraeRawGateway({
|
|
@@ -6415,7 +8334,7 @@ function apply(ctx, config) {
|
|
|
6415
8334
|
runtime: resolved.runtime
|
|
6416
8335
|
}
|
|
6417
8336
|
}),
|
|
6418
|
-
solo:
|
|
8337
|
+
solo: new TraeSoloBridge(primary.client, catalog, wireResolver),
|
|
6419
8338
|
endpoint: `${REGION_GATEWAYS.cn.chat}/api/ide/v2/llm_raw_chat`,
|
|
6420
8339
|
edition: resolved.identity.edition,
|
|
6421
8340
|
identity: {
|
|
@@ -6425,7 +8344,7 @@ function apply(ctx, config) {
|
|
|
6425
8344
|
runtime: resolved.runtime,
|
|
6426
8345
|
enabled: false
|
|
6427
8346
|
});
|
|
6428
|
-
|
|
8347
|
+
primary.upstream.replace(gateway.upstream);
|
|
6429
8348
|
rawDiagnostic = () => gateway.diagnostic();
|
|
6430
8349
|
ctx.effect(() => () => gateway.invalidate(), "dsh-connect-trae: Raw capability invalidation");
|
|
6431
8350
|
} catch (error) {
|
|
@@ -6436,12 +8355,14 @@ function apply(ctx, config) {
|
|
|
6436
8355
|
credential: () => store.resolve(),
|
|
6437
8356
|
deviceId: async () => (await identity()).deviceId
|
|
6438
8357
|
});
|
|
6439
|
-
stacks[
|
|
8358
|
+
stacks[panel] = {
|
|
6440
8359
|
region,
|
|
6441
8360
|
store,
|
|
6442
8361
|
catalog,
|
|
6443
8362
|
shim,
|
|
6444
|
-
|
|
8363
|
+
lastDiscovery: latestDiscovery,
|
|
8364
|
+
delegating: routeClients[providerOfPanel(panel)]?.upstream,
|
|
8365
|
+
routeClients,
|
|
6445
8366
|
usageClient,
|
|
6446
8367
|
/**
|
|
6447
8368
|
* Credit balance of one named account, WITHOUT rebinding the store
|
|
@@ -6451,11 +8372,13 @@ function apply(ctx, config) {
|
|
|
6451
8372
|
* A throwaway client is used on purpose: the shared `usageClient` reads
|
|
6452
8373
|
* `store.resolve()`, which is bound to the selected account, and calling
|
|
6453
8374
|
* `selectAccount` to look at a neighbour would both race concurrent chat
|
|
6454
|
-
* requests and persist a switch the user never made.
|
|
6455
|
-
*
|
|
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.
|
|
6456
8379
|
*/
|
|
6457
8380
|
creditsOfAccount: async (accountId) => {
|
|
6458
|
-
const credential = await store.
|
|
8381
|
+
const credential = await store.resolveCredentialOf(accountId);
|
|
6459
8382
|
if (credential === void 0) return void 0;
|
|
6460
8383
|
return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
|
|
6461
8384
|
},
|
|
@@ -6463,7 +8386,7 @@ function apply(ctx, config) {
|
|
|
6463
8386
|
const state = { id: accountId };
|
|
6464
8387
|
const selectedCredential = await store.current();
|
|
6465
8388
|
const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
|
|
6466
|
-
const credential = store.
|
|
8389
|
+
const credential = store.resolveCredentialOf(accountId);
|
|
6467
8390
|
return new TraeUsageClient({
|
|
6468
8391
|
credential: async () => credential,
|
|
6469
8392
|
deviceId: async () => (await identity()).deviceId
|
|
@@ -6481,7 +8404,7 @@ function apply(ctx, config) {
|
|
|
6481
8404
|
claimCheckinOf: async (accountId) => {
|
|
6482
8405
|
const selectedCredential = await store.current();
|
|
6483
8406
|
const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
|
|
6484
|
-
const credential = store.
|
|
8407
|
+
const credential = store.resolveCredentialOf(accountId);
|
|
6485
8408
|
return new TraeUsageClient({
|
|
6486
8409
|
credential: async () => credential,
|
|
6487
8410
|
deviceId: async () => (await identity()).deviceId
|
|
@@ -6510,6 +8433,8 @@ function apply(ctx, config) {
|
|
|
6510
8433
|
wire.byId.set(model.id, target);
|
|
6511
8434
|
wire.byName.set(model.name.trim().toLowerCase(), target);
|
|
6512
8435
|
}
|
|
8436
|
+
latestDiscovery.length = 0;
|
|
8437
|
+
latestDiscovery.push(...merged);
|
|
6513
8438
|
return merged;
|
|
6514
8439
|
},
|
|
6515
8440
|
/**
|
|
@@ -6532,25 +8457,405 @@ function apply(ctx, config) {
|
|
|
6532
8457
|
const skipped = [];
|
|
6533
8458
|
for (const id of ids) if (known.has(id.trim().toLowerCase())) testable.push(id);
|
|
6534
8459
|
else skipped.push(id);
|
|
6535
|
-
const results = await probeModelsSequentially((body, signal) => upstream.chatStream(body, signal), testable);
|
|
8460
|
+
const results = await probeModelsSequentially((body, signal) => primary.upstream.chatStream(body, signal), testable);
|
|
6536
8461
|
return skipped.length === 0 ? { results } : {
|
|
6537
8462
|
results,
|
|
6538
8463
|
skipped
|
|
6539
8464
|
};
|
|
6540
8465
|
},
|
|
6541
8466
|
invalidateAdapter: () => {},
|
|
6542
|
-
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)
|
|
6543
8661
|
};
|
|
6544
8662
|
}
|
|
6545
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)
|
|
8715
|
+
};
|
|
8716
|
+
}
|
|
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
|
+
/**
|
|
6546
8850
|
* The app's current sign-in when it differs from the bound account (issue
|
|
6547
8851
|
* #29). Pure read: the store resolves the bound account verbatim, so billing
|
|
6548
8852
|
* never moves from here — the card's hint and the follow switch below are
|
|
6549
8853
|
* the only writers.
|
|
6550
8854
|
*/
|
|
6551
|
-
const liveLoginOf = async (
|
|
8855
|
+
const liveLoginOf = async (panel) => {
|
|
8856
|
+
const region = regionOfPanel(panel);
|
|
6552
8857
|
try {
|
|
6553
|
-
const live = await stacks[
|
|
8858
|
+
const live = await stacks[panel].store.liveLogin();
|
|
6554
8859
|
if (live === void 0 || live.region !== region) return void 0;
|
|
6555
8860
|
return {
|
|
6556
8861
|
id: live.id,
|
|
@@ -6574,30 +8879,51 @@ function apply(ctx, config) {
|
|
|
6574
8879
|
* event (`loader/volatile-update`) is what re-runs `applySelection` with the
|
|
6575
8880
|
* updated document — the same channel every card write already uses.
|
|
6576
8881
|
*/
|
|
6577
|
-
const followAppLogin = async (
|
|
6578
|
-
const live = await liveLoginOf(
|
|
8882
|
+
const followAppLogin = async (panel) => {
|
|
8883
|
+
const live = await liveLoginOf(panel);
|
|
6579
8884
|
if (live === void 0 || writeSettings === void 0) return void 0;
|
|
6580
8885
|
const configured = configuredAccountsOf(current());
|
|
6581
|
-
if (configured[
|
|
8886
|
+
if (configured[panel] === live.id) return live;
|
|
6582
8887
|
await writeSettings({ accounts: {
|
|
6583
8888
|
...configured,
|
|
6584
|
-
[
|
|
8889
|
+
[panel]: live.id
|
|
6585
8890
|
} });
|
|
6586
8891
|
return live;
|
|
6587
8892
|
};
|
|
6588
8893
|
ctx.inject(["webServer"], (webCtx) => registerTraeUsageRoute(webCtx, {
|
|
6589
|
-
store: (
|
|
6590
|
-
client: (
|
|
6591
|
-
displayModels: (
|
|
6592
|
-
enabledModelIds: (
|
|
6593
|
-
regionEnabled: (
|
|
6594
|
-
discoverModels: (
|
|
6595
|
-
probeModels: (
|
|
6596
|
-
rawDiagnostic: (
|
|
6597
|
-
creditsOfAccount: (
|
|
6598
|
-
accountStateOf: (
|
|
6599
|
-
claimCheckinOf: (
|
|
6600
|
-
liveLogin: (
|
|
8894
|
+
store: (panel) => stacks[panel].store,
|
|
8895
|
+
client: (panel) => stacks[panel].usageClient,
|
|
8896
|
+
displayModels: (panel) => displayModels(current(), panel),
|
|
8897
|
+
enabledModelIds: (panel) => regionStateOf(current(), panel).enabledModelIds ?? [],
|
|
8898
|
+
regionEnabled: (panel) => regionEnabled(current(), panel),
|
|
8899
|
+
discoverModels: (panel, signal) => stacks[panel].discoverModels(signal),
|
|
8900
|
+
probeModels: (panel, ids) => stacks[panel].probeModels(ids),
|
|
8901
|
+
rawDiagnostic: (panel) => stacks[panel].rawDiagnostic(),
|
|
8902
|
+
creditsOfAccount: (panel, accountId) => stacks[panel].creditsOfAccount(accountId),
|
|
8903
|
+
accountStateOf: (panel, accountId) => stacks[panel].accountStateOf(accountId),
|
|
8904
|
+
claimCheckinOf: (panel, accountId) => stacks[panel].claimCheckinOf(accountId),
|
|
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
|
+
}
|
|
6601
8927
|
}));
|
|
6602
8928
|
/**
|
|
6603
8929
|
* Live registration handles per region, filled once the shim is listening.
|
|
@@ -6609,8 +8935,18 @@ function apply(ctx, config) {
|
|
|
6609
8935
|
* card tab alone would leave every model selectable.
|
|
6610
8936
|
*/
|
|
6611
8937
|
const registration = {
|
|
6612
|
-
cn: {},
|
|
6613
|
-
|
|
8938
|
+
cn: { adapters: [] },
|
|
8939
|
+
enterprise: { adapters: [] },
|
|
8940
|
+
ai: { adapters: [] }
|
|
8941
|
+
};
|
|
8942
|
+
/** Release every live registration this apply() created. */
|
|
8943
|
+
const disposeRegistration = () => {
|
|
8944
|
+
for (const panel of TRAE_PANELS) {
|
|
8945
|
+
for (const entry of registration[panel].adapters) entry.handle();
|
|
8946
|
+
registration[panel].adapters.length = 0;
|
|
8947
|
+
}
|
|
8948
|
+
registration.cn.directory?.();
|
|
8949
|
+
delete registration.cn.directory;
|
|
6614
8950
|
};
|
|
6615
8951
|
/**
|
|
6616
8952
|
* Publish each region's on/off state to the harness (issue #11). Both swaps
|
|
@@ -6622,21 +8958,53 @@ function apply(ctx, config) {
|
|
|
6622
8958
|
* lands immediately.
|
|
6623
8959
|
*/
|
|
6624
8960
|
const syncRegionRegistration = (value) => {
|
|
6625
|
-
for (const
|
|
6626
|
-
|
|
6627
|
-
provider:
|
|
6628
|
-
|
|
8961
|
+
for (const panel of TRAE_PANELS) {
|
|
8962
|
+
const enabled = regionEnabled(value, panel);
|
|
8963
|
+
for (const entry of registration[panel].adapters) entry.handle.replace(enabled ? [entry.provider] : []);
|
|
8964
|
+
}
|
|
8965
|
+
registration.cn.directory?.replace(TRAE_PANELS.filter((panel) => regionEnabled(value, panel)).map((panel) => ({
|
|
8966
|
+
provider: providerOfPanel(panel),
|
|
8967
|
+
displayName: displayNameOfTraeProvider(providerOfPanel(panel)),
|
|
6629
8968
|
settingsNs,
|
|
6630
8969
|
settingsPath: [],
|
|
6631
8970
|
declared: false
|
|
6632
8971
|
})));
|
|
6633
8972
|
};
|
|
6634
|
-
/** Push the current config into every
|
|
8973
|
+
/** Push the current config into every panel's store selection and catalog. */
|
|
6635
8974
|
const applySelection = (value) => {
|
|
6636
|
-
for (const
|
|
6637
|
-
const stack = stacks[
|
|
6638
|
-
stack.store.setSource(unwrapVolatile(value.authFile), unwrapVolatile(value.edition) ?? "auto", effectiveAccountFor(
|
|
6639
|
-
|
|
8975
|
+
for (const panel of TRAE_PANELS) {
|
|
8976
|
+
const stack = stacks[panel];
|
|
8977
|
+
stack.store.setSource(unwrapVolatile(value.authFile), unwrapVolatile(value.edition) ?? "auto", effectiveAccountFor(panel, value));
|
|
8978
|
+
/**
|
|
8979
|
+
* Re-merge the wire mapping the last discovery taught us.
|
|
8980
|
+
*
|
|
8981
|
+
* configuredModels rebuilds from the SAVED rows, and rows saved before
|
|
8982
|
+
* wireConfigName existed carry wireFunction alone. Every settings write
|
|
8983
|
+
* goes through here (card writes, follow-app-login, the initial seed),
|
|
8984
|
+
* so without this each write would strip the case mapping until the next
|
|
8985
|
+
* discovery -- the exact window that turns deepseek-v4.1-flash into a
|
|
8986
|
+
* 4001, because the gateway is case-sensitive and the lowercase
|
|
8987
|
+
* directory id is the one spelling it refuses.
|
|
8988
|
+
*/
|
|
8989
|
+
const known = /* @__PURE__ */ new Map();
|
|
8990
|
+
for (const model of stack.lastDiscovery) {
|
|
8991
|
+
known.set(model.id, model);
|
|
8992
|
+
known.set(model.id.trim().toLowerCase(), model);
|
|
8993
|
+
if (model.name.trim() !== "") known.set(model.name.trim().toLowerCase(), model);
|
|
8994
|
+
}
|
|
8995
|
+
const rows = configuredModels(value, panel).map((model) => {
|
|
8996
|
+
const match = known.get(model.id) ?? known.get(model.id.trim().toLowerCase()) ?? (model.name.trim() !== "" ? known.get(model.name.trim().toLowerCase()) : void 0);
|
|
8997
|
+
if (match === void 0) return model;
|
|
8998
|
+
const wireConfigName = model.wireConfigName ?? match.wireConfigName;
|
|
8999
|
+
const wireFunction = model.wireFunction ?? match.wireFunction;
|
|
9000
|
+
if (wireConfigName === model.wireConfigName && wireFunction === model.wireFunction) return model;
|
|
9001
|
+
return {
|
|
9002
|
+
...model,
|
|
9003
|
+
...wireConfigName === void 0 ? {} : { wireConfigName },
|
|
9004
|
+
...wireFunction === void 0 ? {} : { wireFunction }
|
|
9005
|
+
};
|
|
9006
|
+
});
|
|
9007
|
+
stack.catalog.set(rows);
|
|
6640
9008
|
stack.invalidateAdapter();
|
|
6641
9009
|
}
|
|
6642
9010
|
syncRegionRegistration(value);
|
|
@@ -6695,9 +9063,9 @@ function apply(ctx, config) {
|
|
|
6695
9063
|
if (followInFlight || !followAppLoginEnabled(current())) return;
|
|
6696
9064
|
followInFlight = true;
|
|
6697
9065
|
try {
|
|
6698
|
-
for (const
|
|
6699
|
-
if (!regionEnabled(current(),
|
|
6700
|
-
await followAppLogin(
|
|
9066
|
+
for (const panel of TRAE_PANELS) {
|
|
9067
|
+
if (!regionEnabled(current(), panel)) continue;
|
|
9068
|
+
await followAppLogin(panel);
|
|
6701
9069
|
}
|
|
6702
9070
|
} catch (error) {
|
|
6703
9071
|
ctx.logger.warn("dsh-connect-trae: follow-app-login rebind skipped", error);
|
|
@@ -6709,77 +9077,78 @@ function apply(ctx, config) {
|
|
|
6709
9077
|
let stopped = false;
|
|
6710
9078
|
ctx.effect(() => () => {
|
|
6711
9079
|
stopped = true;
|
|
6712
|
-
for (const
|
|
9080
|
+
for (const panel of TRAE_PANELS) stacks[panel].shim.close();
|
|
6713
9081
|
});
|
|
6714
|
-
Promise.all(
|
|
9082
|
+
Promise.all(TRAE_PANELS.map((panel) => stacks[panel].shim.ready)).then(async () => {
|
|
6715
9083
|
if (stopped) return;
|
|
6716
9084
|
try {
|
|
6717
9085
|
const piAi = piAiRuntimeInfo();
|
|
6718
9086
|
ctx.logger.info(`dsh-connect-trae: pi-ai ${piAi.version ?? "unknown"} (${piAi.generation} generation)${piAi.inspected ? "" : " — not resolved locally, the host supplies it"}`);
|
|
6719
|
-
for (const
|
|
6720
|
-
const stack = stacks[
|
|
9087
|
+
for (const panel of TRAE_PANELS) {
|
|
9088
|
+
const stack = stacks[panel];
|
|
9089
|
+
const provider = providerOfPanel(panel);
|
|
9090
|
+
const entry = stack.routeClients[provider];
|
|
9091
|
+
if (entry === void 0) continue;
|
|
6721
9092
|
const trae = createTraeAdapter({
|
|
6722
9093
|
shim: stack.shim,
|
|
6723
9094
|
catalog: stack.catalog,
|
|
6724
|
-
provider
|
|
6725
|
-
displayName:
|
|
9095
|
+
provider,
|
|
9096
|
+
displayName: displayNameOfTraeProvider(provider),
|
|
9097
|
+
secret: entry.secret,
|
|
6726
9098
|
resolveAttachments: () => ctx.get("attachments")
|
|
6727
9099
|
});
|
|
6728
9100
|
stack.invalidateAdapter = () => {
|
|
6729
9101
|
trae.invalidate();
|
|
6730
9102
|
};
|
|
6731
|
-
registration[
|
|
9103
|
+
registration[panel].adapters.push({
|
|
9104
|
+
provider,
|
|
9105
|
+
handle: ctx.llm.registerAdapter([provider], trae.adapter)
|
|
9106
|
+
});
|
|
6732
9107
|
}
|
|
6733
9108
|
ctx.llm.registerModelDiscovery(settingsNs, async (request, signal) => {
|
|
6734
|
-
const
|
|
6735
|
-
if (
|
|
6736
|
-
if (!regionEnabled(current(),
|
|
9109
|
+
const panel = panelOfProvider(request.provider ?? "");
|
|
9110
|
+
if (panel === void 0) return [];
|
|
9111
|
+
if (!regionEnabled(current(), panel)) return [];
|
|
6737
9112
|
const cancellation = signal ?? request.signal;
|
|
6738
|
-
|
|
9113
|
+
const next = applyImageSelection(await stacks[panel].discoverModels(cancellation), imageSet(current(), panel));
|
|
9114
|
+
const withRate = panel !== "enterprise";
|
|
9115
|
+
return next.map((model) => ({
|
|
6739
9116
|
id: model.id,
|
|
6740
|
-
name: traeModelDisplayName(model),
|
|
9117
|
+
name: traeModelDisplayName(model, withRate),
|
|
6741
9118
|
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
6742
9119
|
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens },
|
|
6743
9120
|
inputModalities: traeInputModalities(model)
|
|
6744
9121
|
}));
|
|
6745
9122
|
});
|
|
6746
|
-
registration.cn.directory = ctx.llm.registerConfigurableProviders(
|
|
6747
|
-
provider:
|
|
6748
|
-
displayName:
|
|
9123
|
+
registration.cn.directory = ctx.llm.registerConfigurableProviders(TRAE_PANELS.map((panel) => ({
|
|
9124
|
+
provider: providerOfPanel(panel),
|
|
9125
|
+
displayName: displayNameOfTraeProvider(providerOfPanel(panel)),
|
|
6749
9126
|
settingsNs,
|
|
6750
9127
|
settingsPath: [],
|
|
6751
9128
|
declared: false
|
|
6752
9129
|
})));
|
|
6753
9130
|
syncRegionRegistration(current());
|
|
6754
9131
|
} finally {
|
|
6755
|
-
if (registration.cn.
|
|
6756
|
-
registration.cn.adapter?.();
|
|
6757
|
-
registration.ai.adapter?.();
|
|
6758
|
-
registration.cn.directory?.();
|
|
6759
|
-
}
|
|
9132
|
+
if (registration.cn.adapters.length === 0 || registration.ai.adapters.length === 0 || registration.cn.directory === void 0) disposeRegistration();
|
|
6760
9133
|
}
|
|
6761
9134
|
try {
|
|
6762
9135
|
ctx.effect(() => () => {
|
|
6763
|
-
|
|
6764
|
-
registration.ai.adapter?.();
|
|
6765
|
-
registration.cn.directory?.();
|
|
9136
|
+
disposeRegistration();
|
|
6766
9137
|
});
|
|
6767
9138
|
} catch {
|
|
6768
|
-
|
|
6769
|
-
registration.ai.adapter?.();
|
|
6770
|
-
registration.cn.directory?.();
|
|
9139
|
+
disposeRegistration();
|
|
6771
9140
|
}
|
|
6772
|
-
for (const
|
|
6773
|
-
if (!regionEnabled(current(),
|
|
9141
|
+
for (const panel of TRAE_PANELS) {
|
|
9142
|
+
if (!regionEnabled(current(), panel)) continue;
|
|
6774
9143
|
(async () => {
|
|
6775
|
-
const stack = stacks[
|
|
9144
|
+
const stack = stacks[panel];
|
|
6776
9145
|
try {
|
|
6777
9146
|
const models = await stack.discoverModels();
|
|
6778
9147
|
if (stopped) return;
|
|
6779
|
-
stack.catalog.set(derive(current(), models,
|
|
9148
|
+
stack.catalog.set(derive(current(), models, panel));
|
|
6780
9149
|
} catch (error) {
|
|
6781
|
-
ctx.logger.warn(`dsh-connect-trae: live ${
|
|
6782
|
-
stack.catalog.set(configuredModels(current(),
|
|
9150
|
+
ctx.logger.warn(`dsh-connect-trae: live ${panel} model directory unavailable; serving the configured catalog`, error);
|
|
9151
|
+
stack.catalog.set(configuredModels(current(), panel));
|
|
6783
9152
|
}
|
|
6784
9153
|
stack.invalidateAdapter();
|
|
6785
9154
|
})();
|
|
@@ -6789,4 +9158,4 @@ function apply(ctx, config) {
|
|
|
6789
9158
|
});
|
|
6790
9159
|
}
|
|
6791
9160
|
//#endregion
|
|
6792
|
-
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_MODELS_TEST_PATH, TRAE_MODEL_DETAIL_FUNCTIONS, TRAE_MODEL_DETAIL_PATH, TRAE_PAY_BASE, TRAE_PROBE_TIMEOUT_MS, TRAE_PROVIDER, TRAE_PROVIDERS, TRAE_PROVIDER_DISPLAY_NAMES, 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, discoveredCatalog, fallbackModelsFor, followAppLoginEnabled, generationOf, hashTraeRawChatArg, identityHeaders, inject, legacyTraeOwnAuthPath, maskUserPath, mergeTraeModelSources, name, nextRegionCreditPosition, nextRegionEnabled, nextRegionSlots, normalizeTraeCredential, normalizeTraeVersionCode, parseObservedModelConfig, parseReasoningCapability, parseTraeAuthValue, parseTraeCachedModel, parseTraeModelExtraConfigLogLine, parseTraeRawChatBehaviorConfig, parseTraeRemoteModel, parseTraeStorageDocument, piAiRuntimeInfo, pickTraeStorageIdentity, prepareSoloBody, probeBody, probeModelsSequentially, probeOneModel, probeTraeRawChatCapability, rawCapabilityDiagnostic, rawCapabilityFingerprint, readTraeCachedModel, readTraeCliIdentity, readTraeIdentity, refreshTraeCredential, regionCreditPositionOf, regionEnabled, regionEnabledOf, regionOfCredential, regionOfEdition, regionOfHost, regionOfTraeProvider, regionOfTraeStatusUrl, regionOfUserRegion, regionStateOf, registerTraeUsageRoute, resolveTraeIdentity, resolveTraeRawRuntime, 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 };
|