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/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 id each region registers as. */
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, id] of Object.entries(TRAE_PROVIDERS)) if (id === provider) return 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
- const buildModels = () => options.catalog.current().map((info) => toPiModel(info, `${options.shim.baseUrl()}/v1`, providerId));
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, region) {
886
- return `${path}?${TRAE_REGION_PARAM}=${region}`;
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 TRAE_REGIONS.includes(value) ? value : void 0;
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, region, slot) {
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
- [region]: slot
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, region) {
1004
- const slot = unwrapVolatile(regionsMapOf(value)[region]);
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, region) {
1018
- return regionSlotOf(value, region)["enabled"] !== false;
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, region, enabled) {
1037
- return nextRegionSlots(regionsMapOf(value), region, {
1038
- ...regionSlotOf(value, region),
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, region) {
1056
- return regionSlotOf(value, region)["creditPosition"] === "stats" ? "stats" : "composer";
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, region, position) {
1070
- return nextRegionSlots(regionsMapOf(value), region, {
1071
- ...regionSlotOf(value, region),
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 accountName = optionalString((typeof value["account"] === "object" && value["account"] !== null && !Array.isArray(value["account"]) ? value["account"] : void 0)?.["username"]);
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 : { username: record["accountName"] },
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
- return credentials.find((credential) => credential.source === "desktop") ?? credentials[0];
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 credentials = await this.readAll();
1903
- const selectedExists = this.accountId !== void 0 && credentials.some((credential) => traeAccountId(credential) === this.accountId);
1904
- const defaultSelected = this.preferred(credentials);
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: selectedExists ? traeAccountId(credential) === this.accountId : credential === defaultSelected
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 credentials = await this.readAll();
1918
- if (this.accountId === void 0) return this.preferred(credentials);
1919
- return credentials.find((credential) => traeAccountId(credential) === this.accountId);
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
- const secret = randomBytes(32).toString("base64url");
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
- function bearerOk(req) {
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 false;
3085
+ if (match === null) return void 0;
2684
3086
  const actual = Buffer.from(match[1] ?? "");
2685
- const expected = Buffer.from(secret);
2686
- return actual.length === expected.length && timingSafeEqual(actual, expected);
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
- if (!bearerOk(req)) return writeError(res, 401, "unauthorized", "Missing or invalid bearer");
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 options.client.chatStream(raw, controller.signal);
2727
- if (!result.ok) return writeError(res, STATUS_BY_KIND[result.kind], result.kind, result.message);
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 ?? REGION_GATEWAYS[region].chat;
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 ?? REGION_GATEWAYS[regionOfCredential(credential)].chat;
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) return {
3214
- ok: true,
3215
- response
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
- //#endregion
3234
- //#region src/sse.ts
3235
- /** Incremental SSE decoder supporting CRLF, chunk splits and multi-line data. */
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 entry = this.catalog?.current().find((item) => item.id === model);
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
- const wireModel = target?.configName ?? model;
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
- /** The minimal chat call one probe makes. */
4634
- function probeBody(model) {
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: "ping"
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. `region` is the tab the card is on; the
5473
- * region-scoped store already answers with only that region's accounts, so the
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, region) {
5479
- const store = deps.store(region);
5480
- const enabled = deps.regionEnabled(region);
5481
- const accounts = await store.accounts();
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?.(region);
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(region).map((model) => ({
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(region)],
5526
- ...deps.rawDiagnostic === void 0 ? {} : { rawChat: deps.rawDiagnostic(region) }
6461
+ enabledModelIds: [...deps.enabledModelIds(panel)],
6462
+ ...deps.rawDiagnostic === void 0 ? {} : { rawChat: deps.rawDiagnostic(panel) }
5527
6463
  };
5528
- const client = deps.client(region);
5529
- if (region === "ai") try {
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, region, accounts) : void 0;
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, region, accounts) {
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 === region).slice(0, TRAE_CREDIT_ALTERNATIVE_LIMIT);
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(region, account.id);
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 region a request addresses, or a 400 answer. Absent parameter means 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 region.
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, region) {
5605
- const accounts = (await deps.store(region).accounts()).filter((account) => account.region === region);
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(region).snapshot()).generalAvailable;
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(region, account.id))?.generalAvailable;
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 region = regionOfTraeStatusUrl(req.url ?? "/");
5625
- if (region === void 0) {
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 region;
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 region = requestRegion(req, res);
5647
- if (region === void 0) return;
6782
+ const panel = requestRegion(req, res);
6783
+ if (panel === void 0) return;
5648
6784
  try {
5649
- json(res, 200, await traeWebUsage(deps, region));
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 region — read only when 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 region = requestRegion(req, res);
5668
- if (region === void 0) return;
6803
+ const panel = requestRegion(req, res);
6804
+ if (panel === void 0) return;
5669
6805
  try {
5670
- json(res, 200, { accounts: await traeAccountCredits(deps, region) });
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 region = requestRegion(req, res);
5691
- if (region === void 0) return;
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(region, accountId);
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 region = requestRegion(req, res);
5710
- if (region === void 0) return;
6845
+ const panel = requestRegion(req, res);
6846
+ if (panel === void 0) return;
5711
6847
  try {
5712
- json(res, 200, { accounts: await deps.store(region).accounts() });
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 region = requestRegion(req, res);
5734
- if (region === void 0) return;
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(region);
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, region);
5769
- if (changes.size > 0) await deps.store(region).importAccounts([...changes.values()]);
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 region = requestRegion(req, res);
5787
- if (region === void 0) return;
6922
+ const panel = requestRegion(req, res);
6923
+ if (panel === void 0) return;
5788
6924
  try {
5789
- json(res, 200, { models: await deps.discoverModels(region) });
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 region = requestRegion(req, res);
5813
- if (region === void 0) return;
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(region, ids));
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 region = requestRegion(req, res);
5856
- if (region === void 0) return;
5857
- if (region !== "cn") return json(res, 404, { error: "check-in is not available for the international region" });
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(region, accountId);
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(region);
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 region's saved state. A config written before the region split has only
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, region) {
6158
- const stored = unwrapVolatile(unwrapVolatile(config.regions)?.[region]);
7955
+ function regionStateOf(config, panel) {
7956
+ const stored = unwrapVolatile(unwrapVolatile(config.regions)?.[panel]);
6159
7957
  if (stored !== void 0) return stored;
6160
- if (region !== "cn") return {};
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 region's provider is switched on (issue #11). Opt-out semantics:
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, region) {
6177
- return regionStateOf(config, region).enabled !== false;
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().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
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-region account selections, keyed cn | ai")),
6246
- regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-region model directory and selection, keyed cn | ai")),
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, region) => new Set(regionStateOf(value, region).enabledModelIds ?? []);
6259
- const imageSet = (value, region) => new Set(regionStateOf(value, region).imageModelIds ?? []);
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 region of REGION_KEYS) wireState[region] = {
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, region) => {
6279
- const wire = wireState[region];
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, region) => applyImageSelection(fallbackModelsFor(region), imageSet(value, region));
6284
- const derive = (value, raw, region) => {
6285
- const selectedImages = imageSet(value, region);
6286
- const derived = deriveCatalog(applyImageSelection(sanitizeCatalog(dropDeadModels(raw, region)), selectedImages), enabledSet(value, region), regionStateOf(value, region).contextBudgets ?? {});
6287
- return derived.length > 0 ? derived : fallbackModels(value, region);
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, region) => {
6290
- const state = regionStateOf(value, region);
6291
- return state.lastCatalog?.length ? derive(value, state.lastCatalog, region) : region === "cn" && value.models?.length ? derive(value, value.models, region) : fallbackModels(value, region);
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, region) => {
6294
- const state = regionStateOf(value, region);
6295
- return state.lastCatalog?.length ? dropDeadModels(sanitizeCatalog(state.lastCatalog), region) : region === "cn" && value.models?.length ? dropDeadModels(sanitizeCatalog(value.models), region) : fallbackModelsFor(region);
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
- const effectiveAccountFor = (region, value) => {
6306
- const explicit = unwrapVolatile(value.accounts)?.[region];
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 region of REGION_KEYS) {
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[region];
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
- const upstream = new TraeSoloBridge(solo, catalog, wireResolver);
6397
- const delegating = new TraeDelegatingUpstreamClient(upstream);
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
- client: delegating,
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 (region === "cn") (async () => {
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: upstream,
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
- delegating.replace(gateway.upstream);
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[region] = {
8358
+ stacks[panel] = {
6440
8359
  region,
6441
8360
  store,
6442
8361
  catalog,
6443
8362
  shim,
6444
- delegating,
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. A stale token simply
6455
- * fails here and the account is left out of the answer.
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.credentialOf(accountId);
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.credentialOf(accountId);
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.credentialOf(accountId);
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 (region) => {
8855
+ const liveLoginOf = async (panel) => {
8856
+ const region = regionOfPanel(panel);
6552
8857
  try {
6553
- const live = await stacks[region].store.liveLogin();
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 (region) => {
6578
- const live = await liveLoginOf(region);
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[region] === live.id) return live;
8886
+ if (configured[panel] === live.id) return live;
6582
8887
  await writeSettings({ accounts: {
6583
8888
  ...configured,
6584
- [region]: live.id
8889
+ [panel]: live.id
6585
8890
  } });
6586
8891
  return live;
6587
8892
  };
6588
8893
  ctx.inject(["webServer"], (webCtx) => registerTraeUsageRoute(webCtx, {
6589
- store: (region) => stacks[region].store,
6590
- client: (region) => stacks[region].usageClient,
6591
- displayModels: (region) => displayModels(current(), region),
6592
- enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
6593
- regionEnabled: (region) => regionEnabled(current(), region),
6594
- discoverModels: (region, signal) => stacks[region].discoverModels(signal),
6595
- probeModels: (region, ids) => stacks[region].probeModels(ids),
6596
- rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
6597
- creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId),
6598
- accountStateOf: (region, accountId) => stacks[region].accountStateOf(accountId),
6599
- claimCheckinOf: (region, accountId) => stacks[region].claimCheckinOf(accountId),
6600
- liveLogin: (region) => liveLoginOf(region)
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
- ai: {}
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 region of REGION_KEYS) registration[region].adapter?.replace(regionEnabled(value, region) ? [TRAE_PROVIDERS[region]] : []);
6626
- registration.cn.directory?.replace(REGION_KEYS.filter((region) => regionEnabled(value, region)).map((region) => ({
6627
- provider: TRAE_PROVIDERS[region],
6628
- displayName: TRAE_PROVIDER_DISPLAY_NAMES[region],
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 region's store selection and catalog. */
8973
+ /** Push the current config into every panel's store selection and catalog. */
6635
8974
  const applySelection = (value) => {
6636
- for (const region of REGION_KEYS) {
6637
- const stack = stacks[region];
6638
- stack.store.setSource(unwrapVolatile(value.authFile), unwrapVolatile(value.edition) ?? "auto", effectiveAccountFor(region, value));
6639
- stack.catalog.set(configuredModels(value, region));
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 region of REGION_KEYS) {
6699
- if (!regionEnabled(current(), region)) continue;
6700
- await followAppLogin(region);
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 region of REGION_KEYS) stacks[region].shim.close();
9080
+ for (const panel of TRAE_PANELS) stacks[panel].shim.close();
6713
9081
  });
6714
- Promise.all(REGION_KEYS.map((region) => stacks[region].shim.ready)).then(async () => {
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 region of REGION_KEYS) {
6720
- const stack = stacks[region];
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: TRAE_PROVIDERS[region],
6725
- displayName: TRAE_PROVIDER_DISPLAY_NAMES[region],
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[region].adapter = region === "cn" ? ctx.llm.registerAdapter([TRAE_PROVIDER], trae.adapter) : ctx.llm.registerAdapter([TRAE_AI_PROVIDER], trae.adapter);
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 region = regionOfTraeProvider(request.provider ?? "");
6735
- if (region === void 0) return [];
6736
- if (!regionEnabled(current(), region)) return [];
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
- return applyImageSelection(await stacks[region].discoverModels(cancellation), imageSet(current(), region)).map((model) => ({
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(REGION_KEYS.map((region) => ({
6747
- provider: TRAE_PROVIDERS[region],
6748
- displayName: TRAE_PROVIDER_DISPLAY_NAMES[region],
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.adapter === void 0 || registration.ai.adapter === void 0 || registration.cn.directory === void 0) {
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
- registration.cn.adapter?.();
6764
- registration.ai.adapter?.();
6765
- registration.cn.directory?.();
9136
+ disposeRegistration();
6766
9137
  });
6767
9138
  } catch {
6768
- registration.cn.adapter?.();
6769
- registration.ai.adapter?.();
6770
- registration.cn.directory?.();
9139
+ disposeRegistration();
6771
9140
  }
6772
- for (const region of REGION_KEYS) {
6773
- if (!regionEnabled(current(), region)) continue;
9141
+ for (const panel of TRAE_PANELS) {
9142
+ if (!regionEnabled(current(), panel)) continue;
6774
9143
  (async () => {
6775
- const stack = stacks[region];
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, region));
9148
+ stack.catalog.set(derive(current(), models, panel));
6780
9149
  } catch (error) {
6781
- ctx.logger.warn(`dsh-connect-trae: live ${region} model directory unavailable; serving the configured catalog`, error);
6782
- stack.catalog.set(configuredModels(current(), region));
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 };