dsh-connect-trae 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -235,12 +235,15 @@ function mergeTraeModelSources(remote, wire) {
235
235
  for (const model of remote) {
236
236
  const wireModel = wireById.get(displayKey(model.id)) ?? wireByName.get(displayKey(model.name));
237
237
  const creditMultiplier = wireModel?.creditMultiplier ?? model.creditMultiplier;
238
+ const wireFunction = wireModel?.function ?? model.function;
239
+ const activityDiscount = wireModel?.activityDiscount ?? model.activityDiscount;
238
240
  result.push({
239
241
  id: model.id,
240
242
  name: model.name,
241
243
  ...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
242
244
  ...model.maxContextWindow === void 0 ? {} : { maxContextWindow: model.maxContextWindow },
243
245
  ...creditMultiplier === void 0 ? {} : { creditMultiplier },
246
+ ...activityDiscount === void 0 ? {} : { activityDiscount },
244
247
  input: ["text"],
245
248
  ...wireModel?.maxTokens === void 0 ? {} : { maxTokens: wireModel.maxTokens },
246
249
  ...model.multimodal === void 0 ? {} : { multimodal: model.multimodal },
@@ -250,12 +253,45 @@ function mergeTraeModelSources(remote, wire) {
250
253
  reasoningEfforts: Object.fromEntries(model.reasoning.supported.map((effort) => [effort, effort === "low" ? "light" : effort === "xhigh" ? "extra_high" : "high"]))
251
254
  },
252
255
  ...wireModel !== void 0 && wireModel.id !== "" && wireModel.id !== model.id ? { wireConfigName: wireModel.id } : {},
253
- ...wireModel?.function === void 0 ? {} : { wireFunction: wireModel.function }
256
+ ...wireFunction === void 0 ? {} : { wireFunction }
254
257
  });
255
258
  }
256
259
  return result;
257
260
  }
258
261
  /**
262
+ * Demote a NON-chat `wireFunction` to "no function", keeping the row.
263
+ *
264
+ * A row pinned to an IDE/Remote-agent roster — `solo_agent`, `builder_v3` — is
265
+ * not callable through `llm_utils_chat` by THAT function: asking answers
266
+ * 4011/4001 (measured 2026-10-01, issue #19). Dropping the row is not the fix,
267
+ * though, and was a regression: a model can carry a non-chat function for a
268
+ * reason that says nothing about its chat availability. The directory lists
269
+ * `gpt-5.5`, `gpt-5.6-*`, `gpt-6-astra`, `glm-5.2`, `kimi-k2.7-code`,
270
+ * `Dola-Seed-2.0-Code` and `deepseek-v4-flash-0731` under `solo_agent` ALONE
271
+ * (docs/ISSUE19_DIAGNOSIS.md), yet the same models answer normally under
272
+ * `solo_work_remote` / `solo_work_lite` / `solo_agent_remote` — they are
273
+ * exactly the models issue #19's fix was opened to expose. Dropping them
274
+ * re-hid the whole set on the restricted networks this fallback targets, where
275
+ * the wire roster is empty and the directory group is the only attribution.
276
+ *
277
+ * Keeping the row with the function CLEARED preserves both facts: the model
278
+ * stays visible (issue #19), and the bridge sends the region's DEFAULT chat
279
+ * function instead of one that is guaranteed to refuse. That is the pre-change
280
+ * behaviour for these rows — a genuinely unreachable model still fails with a
281
+ * readable upstream message rather than a silent absence.
282
+ *
283
+ * `chatFunctions` must be the region's own `TRAE_DIRECTORY_FUNCTIONS` list —
284
+ * the functions `llm_utils_chat` accepts. Callers should log the demoted rows:
285
+ * "which function did it lose, and why" deserves an answer.
286
+ */
287
+ function withChatCallableFunctions(rows, chatFunctions) {
288
+ return rows.map((model) => {
289
+ if (model.wireFunction === void 0 || chatFunctions.includes(model.wireFunction)) return model;
290
+ const { wireFunction: _demoted, ...withoutFunction } = model;
291
+ return withoutFunction;
292
+ });
293
+ }
294
+ /**
259
295
  * Apply the saved local budget. Trae advertises two windows per model (dev and
260
296
  * Max), so the budget may only switch a model to its own advertised Max value —
261
297
  * never to a fabricated number. Everything else keeps the dev window.
@@ -276,7 +312,9 @@ function discoveredCatalog(models) {
276
312
  ...model.maxContextWindow === void 0 ? {} : { maxContextWindow: model.maxContextWindow },
277
313
  input: ["text"],
278
314
  ...model.creditMultiplier === void 0 ? {} : { creditMultiplier: model.creditMultiplier },
315
+ ...model.activityDiscount === void 0 ? {} : { activityDiscount: model.activityDiscount },
279
316
  ...model.multimodal === void 0 ? {} : { multimodal: model.multimodal },
317
+ ...model.function === void 0 ? {} : { wireFunction: model.function },
280
318
  reasoningSupported: model.reasoningSupported,
281
319
  ...model.reasoning === void 0 ? {} : {
282
320
  reasoning: model.reasoning,
@@ -3703,6 +3741,97 @@ function traeEndpoint(baseUrl, path) {
3703
3741
  return `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
3704
3742
  }
3705
3743
  //#endregion
3744
+ //#region src/promo.ts
3745
+ function record$1(value) {
3746
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
3747
+ }
3748
+ function finite(value) {
3749
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3750
+ }
3751
+ function percent(value) {
3752
+ const parsed = finite(value);
3753
+ return parsed !== void 0 && parsed > 0 && parsed < 100 ? parsed : void 0;
3754
+ }
3755
+ function ratePair(raw) {
3756
+ const entry = record$1(raw);
3757
+ if (entry === void 0) return {};
3758
+ const beforeRate = finite(entry["before_consumption_rate"]);
3759
+ const rate = finite(entry["consumption_rate"]) ?? finite(entry["after_consumption_rate"]);
3760
+ const discountPercent = percent(entry["discount"]);
3761
+ return {
3762
+ ...beforeRate === void 0 || beforeRate <= 0 ? {} : { beforeRate },
3763
+ ...rate === void 0 || rate <= 0 ? {} : { rate },
3764
+ ...discountPercent === void 0 ? {} : { discountPercent }
3765
+ };
3766
+ }
3767
+ function windows(raw) {
3768
+ const list = Array.isArray(raw) ? raw : [];
3769
+ const parsed = [];
3770
+ for (const item of list) {
3771
+ const entry = record$1(item);
3772
+ if (entry === void 0) continue;
3773
+ const weekdays = Array.isArray(entry["weekdays"]) ? entry["weekdays"].filter((day) => Number.isInteger(day) && day >= 1 && day <= 7) : [];
3774
+ const startMinute = finite(entry["start_minute"]);
3775
+ const endMinute = finite(entry["end_minute"]);
3776
+ if (weekdays.length === 0 || startMinute === void 0 || endMinute === void 0) continue;
3777
+ if (startMinute < 0 || startMinute > 1439 || endMinute < 1 || endMinute > 1440) continue;
3778
+ if (startMinute >= endMinute) continue;
3779
+ parsed.push({
3780
+ weekdays,
3781
+ startMinute,
3782
+ endMinute
3783
+ });
3784
+ }
3785
+ return parsed;
3786
+ }
3787
+ /**
3788
+ * Parse one `activity_discount` document.
3789
+ *
3790
+ * `value` may be the already-parsed object (both call sites hand us the parent
3791
+ * object) or the raw JSON string; anything without `enable: true` reads as "no
3792
+ * promotion" rather than as a rule we might later misjudge.
3793
+ */
3794
+ function parseTraeActivityDiscount(value) {
3795
+ let source = value;
3796
+ if (typeof source === "string") {
3797
+ if (source === "") return void 0;
3798
+ try {
3799
+ source = JSON.parse(source);
3800
+ } catch {
3801
+ return;
3802
+ }
3803
+ }
3804
+ const raw = record$1(source);
3805
+ if (raw === void 0 || raw["enable"] !== true) return void 0;
3806
+ const data = record$1(raw["data"]);
3807
+ if (data === void 0) return void 0;
3808
+ const subKey = typeof raw["subKey"] === "string" && raw["subKey"] !== "" ? raw["subKey"] : void 0;
3809
+ const currentRaw = record$1(data["current"]);
3810
+ const currentType = typeof currentRaw?.["discount_type"] === "string" ? currentRaw["discount_type"] : void 0;
3811
+ const current = currentRaw === void 0 ? void 0 : {
3812
+ ...ratePair(currentRaw),
3813
+ ...currentType === void 0 ? {} : { discountType: currentType }
3814
+ };
3815
+ const memberRaw = data["member"];
3816
+ const member = record$1(memberRaw) === void 0 ? void 0 : ratePair(memberRaw);
3817
+ const subsidyRaw = data["subsidy"];
3818
+ const subsidy = record$1(subsidyRaw) === void 0 ? void 0 : ratePair(subsidyRaw);
3819
+ const offPeakRaw = record$1(data["off_peak"]);
3820
+ const offPeak = offPeakRaw === void 0 ? void 0 : {
3821
+ ...ratePair(offPeakRaw),
3822
+ ...typeof offPeakRaw["time_zone"] === "string" && offPeakRaw["time_zone"] !== "" ? { timeZone: offPeakRaw["time_zone"] } : {},
3823
+ windows: windows(offPeakRaw["time_windows"])
3824
+ };
3825
+ const parsed = {
3826
+ ...subKey === void 0 ? {} : { subKey },
3827
+ ...current === void 0 ? {} : { current },
3828
+ ...member === void 0 ? {} : { member },
3829
+ ...offPeak === void 0 ? {} : { offPeak },
3830
+ ...subsidy === void 0 ? {} : { subsidy }
3831
+ };
3832
+ return parsed.current === void 0 && parsed.member === void 0 && parsed.offPeak === void 0 && parsed.subsidy === void 0 ? void 0 : parsed;
3833
+ }
3834
+ //#endregion
3706
3835
  //#region src/reasoning.ts
3707
3836
  const TRAE_REASONING_EFFORTS = [
3708
3837
  "minimal",
@@ -3870,6 +3999,29 @@ function prepareSoloBody(source, defaultModel = "glm-5.2", functionName) {
3870
3999
  return JSON.stringify(body);
3871
4000
  }
3872
4001
  /**
4002
+ * Parse this row's `display_contact_config` document.
4003
+ *
4004
+ * It is a *string* holding a second JSON document, and three different readers
4005
+ * (the credit multiplier, the promotion rule, and whatever upstream adds next)
4006
+ * need it — so it is decoded once here rather than three times.
4007
+ */
4008
+ function wireContactConfig(config) {
4009
+ const raw = config["display_contact_config"];
4010
+ if (typeof raw !== "string" || raw === "") return void 0;
4011
+ let parsed;
4012
+ try {
4013
+ parsed = JSON.parse(raw);
4014
+ } catch {
4015
+ return;
4016
+ }
4017
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
4018
+ return parsed;
4019
+ }
4020
+ /** The promotion rule from `display_contact_config`, when the row carries one. */
4021
+ function wireActivityDiscount(config) {
4022
+ return parseTraeActivityDiscount(wireContactConfig(config)?.["activity_discount"]);
4023
+ }
4024
+ /**
3873
4025
  * Read the effective credit multiplier from a `get_detail_param` row.
3874
4026
  *
3875
4027
  * The rate the Trae IDE renders lives in `display_contact_config` — a *string*
@@ -3885,15 +4037,8 @@ function prepareSoloBody(source, defaultModel = "glm-5.2", functionName) {
3885
4037
  * field keep their bare name — parsed-but-absent, never fabricated.
3886
4038
  */
3887
4039
  function wireCreditMultiplier(config) {
3888
- const raw = config["display_contact_config"];
3889
- if (typeof raw !== "string" || raw === "") return void 0;
3890
- let parsed;
3891
- try {
3892
- parsed = JSON.parse(raw);
3893
- } catch {
3894
- return;
3895
- }
3896
- if (typeof parsed !== "object" || parsed === null) return void 0;
4040
+ const parsed = wireContactConfig(config);
4041
+ if (parsed === void 0) return void 0;
3897
4042
  const consumption = parsed["consumption_rate"];
3898
4043
  if (typeof consumption !== "object" || consumption === null) return void 0;
3899
4044
  const entry = consumption;
@@ -4001,6 +4146,7 @@ var TraeSoloUpstreamClient = class {
4001
4146
  ...detail
4002
4147
  });
4003
4148
  const creditMultiplier = wireCreditMultiplier(config);
4149
+ const activityDiscount = wireActivityDiscount(config);
4004
4150
  if (byId.has(id)) continue;
4005
4151
  byId.set(id, {
4006
4152
  id,
@@ -4009,6 +4155,7 @@ var TraeSoloUpstreamClient = class {
4009
4155
  ...maxTokens === void 0 ? {} : { maxTokens },
4010
4156
  ...reasoning === void 0 ? {} : { reasoning },
4011
4157
  ...creditMultiplier === void 0 ? {} : { creditMultiplier },
4158
+ ...activityDiscount === void 0 ? {} : { activityDiscount },
4012
4159
  function: directoryFunction
4013
4160
  });
4014
4161
  }
@@ -4702,8 +4849,9 @@ var TraeSoloBridge = class {
4702
4849
  if (typeof input["model"] === "string" && input["model"] !== "") model = input["model"];
4703
4850
  const entryKey = model.trim().toLowerCase();
4704
4851
  const entry = this.catalog?.current().find((item) => item.id === model || item.id.trim().toLowerCase() === entryKey);
4705
- const fromCatalog = entry?.wireConfigName === void 0 ? void 0 : {
4706
- configName: entry.wireConfigName,
4852
+ const catalogConfigName = entry?.wireConfigName;
4853
+ const fromCatalog = entry === void 0 || catalogConfigName === void 0 && entry.wireFunction === void 0 ? void 0 : {
4854
+ configName: catalogConfigName ?? entry.id,
4707
4855
  ...entry.wireFunction === void 0 ? {} : { function: entry.wireFunction }
4708
4856
  };
4709
4857
  const fromResolver = this.wireResolver?.(model) ?? this.wireResolver?.(entry?.name ?? "");
@@ -4718,11 +4866,28 @@ var TraeSoloBridge = class {
4718
4866
  * directory, so borrowing its function is safe exactly when both sources
4719
4867
  * agree on the wire config_name; a disagreement means the resolver found
4720
4868
  * a different row and its function would be wrong.
4869
+ *
4870
+ * The MIRROR case is equally load-bearing and is the reason the row's own
4871
+ * id is not accepted as its wire spelling: a row that carries only the
4872
+ * FUNCTION (`wireConfigName` absent) has no config_name of its own, and
4873
+ * its display id is the wrong-cased spelling of the wire name in exactly
4874
+ * the cases issue #38 is about. The resolver's config_name is then the
4875
+ * only correct one, so it must win for the NAME while the row keeps
4876
+ * winning for the FUNCTION.
4721
4877
  */
4722
4878
  const sameWireConfig = fromCatalog !== void 0 && fromResolver !== void 0 && fromCatalog.configName.toLowerCase() === fromResolver.configName.toLowerCase();
4879
+ /**
4880
+ * Whether the catalog row knows its own wire `config_name` (as opposed to
4881
+ * only carrying the display id plus a function). Only such a row may
4882
+ * decide the name on its own.
4883
+ */
4884
+ const catalogKnowsConfigName = catalogConfigName !== void 0;
4723
4885
  const target = fromCatalog !== void 0 && fromCatalog.function === void 0 && sameWireConfig && fromResolver !== void 0 ? {
4724
4886
  configName: fromCatalog.configName,
4725
4887
  ...fromResolver.function === void 0 ? {} : { function: fromResolver.function }
4888
+ } : fromCatalog !== void 0 && !catalogKnowsConfigName && fromResolver !== void 0 ? {
4889
+ configName: fromResolver.configName,
4890
+ ...fromCatalog.function === void 0 ? {} : { function: fromCatalog.function }
4726
4891
  } : fromCatalog ?? fromResolver;
4727
4892
  /**
4728
4893
  * Last line of defence: the discovered row's own id.
@@ -4786,8 +4951,15 @@ const EFFORT_MAP = {
4786
4951
  high: "high",
4787
4952
  extra_high: "xhigh"
4788
4953
  };
4789
- /** Parse only capabilities explicitly advertised by Trae's remote model API. */
4790
- function parseTraeRemoteModel(value) {
4954
+ /**
4955
+ * Parse only capabilities explicitly advertised by Trae's remote model API.
4956
+ *
4957
+ * @param groupFunction The `data.list[].function` of the group this raw row
4958
+ * was read from; attached verbatim so downstream can replay the model's own
4959
+ * function instead of the `llm_utils_chat` default (see
4960
+ * {@link TraeDiscoveredModel.function}).
4961
+ */
4962
+ function parseTraeRemoteModel(value, groupFunction) {
4791
4963
  const raw = record(value);
4792
4964
  if (raw === void 0 || typeof raw.name !== "string" || raw.name === "") return void 0;
4793
4965
  const context = record(raw.context_window_tokens);
@@ -4798,6 +4970,7 @@ function parseTraeRemoteModel(value) {
4798
4970
  const consumptionData = record(consumption?.["data"]);
4799
4971
  const creditMultiplier = consumption?.["enable"] === true ? finitePositive(consumptionData?.["rate"]) : void 0;
4800
4972
  const reasoningSupported = record(features?.["reasoning"])?.["enable"] === true;
4973
+ const activityDiscount = parseTraeActivityDiscount(features?.["activity_discount"]);
4801
4974
  const reasoningConfig = record(raw.reasoning_effort_config);
4802
4975
  const supported = (Array.isArray(reasoningConfig?.["options"]) ? reasoningConfig["options"] : []).flatMap((option) => {
4803
4976
  if (typeof option !== "string") return [];
@@ -4814,11 +4987,13 @@ function parseTraeRemoteModel(value) {
4814
4987
  ...dev === void 0 ? {} : { contextWindow: dev },
4815
4988
  ...max === void 0 ? {} : { maxContextWindow: max },
4816
4989
  ...creditMultiplier === void 0 ? {} : { creditMultiplier },
4990
+ ...activityDiscount === void 0 ? {} : { activityDiscount },
4817
4991
  reasoningSupported,
4818
4992
  ...supported.length === 0 ? {} : { reasoning: {
4819
4993
  supported,
4820
4994
  ...defaultEffort === void 0 ? {} : { defaultEffort }
4821
- } }
4995
+ } },
4996
+ ...groupFunction === void 0 || groupFunction === "" ? {} : { function: groupFunction }
4822
4997
  };
4823
4998
  }
4824
4999
  //#endregion
@@ -4885,8 +5060,24 @@ const TRAE_REMOTE_DIRECTORY_FUNCTIONS = {
4885
5060
  ]
4886
5061
  };
4887
5062
  /**
4888
- * Of two rows for the SAME model id, the one that advertises the stronger
4889
- * capacity (issue #23).
5063
+ * Backup bases for the model directory, in try order after the official base.
5064
+ *
5065
+ * In restricted (proxy-sandboxed) networks the official catalog host
5066
+ * `solo.trae.cn` is behind a CONNECT rejection while the region's chat gateway
5067
+ * stays reachable — and that gateway serves the SAME `/api/remote/v1/models`
5068
+ * endpoint with an identical response shape (verified against
5069
+ * `https://trae-api-cn.mchost.guru/api/remote/v1` 2026-10-20; `chat` already
5070
+ * carries `/api/remote/v1`'s parent in {@link REGION_GATEWAYS}). The backup is
5071
+ * therefore derived from the region's chat base, mirrored on the remote path;
5072
+ * `ai` shares a single host for chat and remote, so it yields no candidate and
5073
+ * keeps its single-host behavior. An explicit `baseUrl` disables the chain.
5074
+ */
5075
+ function traeSoloRemoteFallbackBases(region, officialBase) {
5076
+ const backupBase = `${REGION_GATEWAYS[region].chat}/api/remote/v1`;
5077
+ return backupBase === officialBase ? [] : [backupBase];
5078
+ }
5079
+ /**
5080
+ * Of two rows for the SAME model id, the one the catalog should keep.
4890
5081
  *
4891
5082
  * The gateway groups its directory by function and reports a model's capacity
4892
5083
  * per group, with the group order varying between calls. Keeping the strongest
@@ -4894,6 +5085,9 @@ const TRAE_REMOTE_DIRECTORY_FUNCTIONS = {
4894
5085
  * LEXICOGRAPHIC and must be applied all the way down, or ordering leaks back in
4895
5086
  * at the next field:
4896
5087
  *
5088
+ * 0. the kept row must also carry the promotional rule, which the groups
5089
+ * disagree about — the rule is copied onto the capacity winner, and of two
5090
+ * rules the one that can say more wins (see the comment in the body);
4897
5091
  * 1. a row with a Max tier beats one without (`maxContextWindow` is only set
4898
5092
  * when the group reported `max_mode: true` with a positive max);
4899
5093
  * 2. two Max rows: the larger max wins;
@@ -4911,6 +5105,52 @@ const TRAE_REMOTE_DIRECTORY_FUNCTIONS = {
4911
5105
  */
4912
5106
  function preferStrongerRow(candidate, incumbent) {
4913
5107
  if (incumbent === void 0) return candidate;
5108
+ const winner = strongerCapacityRow(candidate, incumbent);
5109
+ const rule = richerRule(candidate.activityDiscount, incumbent.activityDiscount);
5110
+ if (rule === void 0 || rule === winner.activityDiscount) return winner;
5111
+ return {
5112
+ ...winner,
5113
+ activityDiscount: rule
5114
+ };
5115
+ }
5116
+ /**
5117
+ * Step 0's tie-break: of two rules for the same model id, the one that can say
5118
+ * more about this moment. Every parsed rule already passed `enable: true` (see
5119
+ * parseTraeActivityDiscount), so the ranking is: an upstream "right now"
5120
+ * discount first, then the rule covering more clock. Two rules that rank EQUAL
5121
+ * are compared by their canonical content, so the answer depends on the rule
5122
+ * documents and never on which group the gateway answered first (issue #23).
5123
+ */
5124
+ function richerRule(candidate, incumbent) {
5125
+ if (candidate === void 0) return incumbent;
5126
+ if (incumbent === void 0) return candidate;
5127
+ const score = (rule) => {
5128
+ return (rule.current !== void 0 && (rule.current.discountType ?? "none") !== "none" ? 1 : 0) * 1e3 + (rule.offPeak?.windows.length ?? 0);
5129
+ };
5130
+ const candidateScore = score(candidate);
5131
+ const incumbentScore = score(incumbent);
5132
+ if (candidateScore !== incumbentScore) return candidateScore > incumbentScore ? candidate : incumbent;
5133
+ return canonicalRule(candidate) < canonicalRule(incumbent) ? candidate : incumbent;
5134
+ }
5135
+ /**
5136
+ * Content-only identity for two rules that scored the same.
5137
+ *
5138
+ * The score cannot distinguish, say, two `current: none` rules with two windows
5139
+ * each in different hours — and keeping "whichever arrived first" would let the
5140
+ * gateway's group order decide whether tonight's badge appears. Key order is
5141
+ * normalised (the objects are ours, but a caller may hand us a literal), so
5142
+ * equal documents compare equal and the incumbent is kept unchanged.
5143
+ */
5144
+ function canonicalRule(rule) {
5145
+ const canonical = (value) => {
5146
+ if (Array.isArray(value)) return value.map((item) => canonical(item));
5147
+ if (typeof value !== "object" || value === null) return value;
5148
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => [key, canonical(item)]));
5149
+ };
5150
+ return JSON.stringify(canonical(rule));
5151
+ }
5152
+ /** Steps 1–4 of the documented order: the row advertising the stronger capacity. */
5153
+ function strongerCapacityRow(candidate, incumbent) {
4914
5154
  const candidateMax = candidate.maxContextWindow;
4915
5155
  const incumbentMax = incumbent.maxContextWindow;
4916
5156
  if (candidateMax !== void 0 && incumbentMax === void 0) return candidate;
@@ -4942,10 +5182,39 @@ function remoteDressing(region) {
4942
5182
  * This deliberately has no chat/session method: the Remote session protocol
4943
5183
  * only exposes a final answer and cannot preserve DSH's structured tool loop.
4944
5184
  */
4945
- var TraeSoloRemoteCatalogClient = class {
5185
+ var TraeSoloRemoteCatalogClient = class TraeSoloRemoteCatalogClient {
4946
5186
  options;
4947
5187
  fetchImpl;
4948
5188
  baseUrl;
5189
+ /**
5190
+ * The last base that answered the directory with a NON-EMPTY catalog in this
5191
+ * process.
5192
+ *
5193
+ * A proxy-blocked network pays the failing official attempt on EVERY refresh
5194
+ * otherwise, so a backup that has served the catalog is tried first on later
5195
+ * refreshes. Putting the official base "second" is NOT enough to re-probe it:
5196
+ * the loop returns on the first success, so a backup that keeps answering
5197
+ * means the official base is never asked again. The official host is
5198
+ * therefore re-probed on a COOLDOWN instead (see {@link OFFICIAL_RETRY_MS}):
5199
+ * while the cooldown runs the backup is used and the official host is
5200
+ * skipped; when it expires the official host is tried FIRST again, so a
5201
+ * healed network takes over within one cooldown window rather than never.
5202
+ */
5203
+ lastSuccessfulBase;
5204
+ /**
5205
+ * When the official base may be probed again after a transport/5xx failure,
5206
+ * as epoch milliseconds (0 = probe it on the next refresh).
5207
+ */
5208
+ officialRetryAtMs = 0;
5209
+ /**
5210
+ * How long a failing official host is skipped before it is re-probed.
5211
+ *
5212
+ * Long enough that a blocked network stops paying the failing attempt on
5213
+ * every refresh (the point of the fallback), short enough that a healed
5214
+ * network takes over without a restart. Each probe costs one connect timeout
5215
+ * (30 s) in the worst case, so 5 minutes bounds that to ~1 attempt per 5 min.
5216
+ */
5217
+ static OFFICIAL_RETRY_MS = 3e5;
4949
5218
  constructor(options) {
4950
5219
  this.options = options;
4951
5220
  this.fetchImpl = options.fetchImpl ?? fetch;
@@ -4966,24 +5235,183 @@ var TraeSoloRemoteCatalogClient = class {
4966
5235
  }
4967
5236
  async fetchModels(signal) {
4968
5237
  const region = regionOfCredential(await this.options.credential());
4969
- const base = this.baseUrl ?? REGION_GATEWAYS[region].remote;
5238
+ const officialBase = this.baseUrl ?? REGION_GATEWAYS[region].remote;
4970
5239
  const headers = await this.headers(region);
4971
5240
  const functions = TRAE_REMOTE_DIRECTORY_FUNCTIONS[region].join(",");
4972
- const response = await this.fetchImpl(`${base}/models?functions=${functions}`, {
4973
- headers,
4974
- signal: signal ?? AbortSignal.timeout(3e4)
4975
- });
4976
- if (!response.ok) throw new Error(`SOLO remote models returned HTTP ${response.status}`);
4977
- const groups = (await response.json()).data?.list ?? [];
5241
+ let lastError;
5242
+ let emptyError;
5243
+ for (const base of this.candidateBases(region, officialBase)) {
5244
+ let response;
5245
+ try {
5246
+ response = await this.fetchImpl(`${base}/models?functions=${functions}`, {
5247
+ headers,
5248
+ signal: signal ?? AbortSignal.timeout(3e4)
5249
+ });
5250
+ } catch (error) {
5251
+ if (signal?.aborted) throw error;
5252
+ if (base === officialBase) this.officialRetryAtMs = Date.now() + TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS;
5253
+ lastError = error;
5254
+ continue;
5255
+ }
5256
+ if (!response.ok) {
5257
+ if (response.status < 500) throw new Error(`SOLO remote models returned HTTP ${response.status}`);
5258
+ if (base === officialBase) this.officialRetryAtMs = Date.now() + TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS;
5259
+ lastError = /* @__PURE__ */ new Error(`SOLO remote models returned HTTP ${response.status}`);
5260
+ continue;
5261
+ }
5262
+ const json = await response.json();
5263
+ const models = this.collectModels(region, json.data?.list ?? []);
5264
+ if (models.length === 0) {
5265
+ emptyError = /* @__PURE__ */ new Error("SOLO remote models response contained no models");
5266
+ lastError = emptyError;
5267
+ continue;
5268
+ }
5269
+ if (base !== officialBase) this.options.log?.(this.lastSuccessfulBase === base ? `model directory served by the remembered fallback host ${base}; the official host (${officialBase}) is re-probed every ${TraeSoloRemoteCatalogClient.OFFICIAL_RETRY_MS / 6e4} minutes` : `model directory served by fallback host ${base}; the official host (${officialBase}) failed this refresh`, { officialBase });
5270
+ else this.officialRetryAtMs = 0;
5271
+ this.lastSuccessfulBase = base;
5272
+ return models;
5273
+ }
5274
+ if (emptyError !== void 0) throw emptyError;
5275
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
5276
+ }
5277
+ /**
5278
+ * Bases to try for one refresh, in order.
5279
+ *
5280
+ * While the official host is in its post-failure cooldown the backup hosts go
5281
+ * first and the official base is kept LAST as a final chance (so a still-down
5282
+ * backup does not fail the whole refresh while the official host happens to
5283
+ * be reachable). Once the cooldown expires the official base is first again:
5284
+ * that is what lets a healed network take over — the loop returns on the
5285
+ * first success, so simply placing the official base second never re-probes
5286
+ * it while a backup keeps answering.
5287
+ */
5288
+ candidateBases(region, officialBase) {
5289
+ if (this.baseUrl !== void 0) return [officialBase];
5290
+ const fallbacks = traeSoloRemoteFallbackBases(region, officialBase);
5291
+ if (this.officialRetryAtMs <= Date.now()) return [officialBase, ...fallbacks];
5292
+ const remembered = this.lastSuccessfulBase;
5293
+ return [
5294
+ ...remembered === void 0 ? [] : [remembered],
5295
+ ...fallbacks.filter((base) => base !== remembered),
5296
+ officialBase
5297
+ ];
5298
+ }
5299
+ /**
5300
+ * Union every returned group into one deduplicated list, attributing each
5301
+ * model to the CHAT function the directory listed it under.
5302
+ *
5303
+ * Each group is a roster the gateway is willing to serve; a model listed by
5304
+ * any of them is a model the IDE can offer, and the wire join downstream (see
5305
+ * mergeTraeModelSources) is what decides whether it is actually callable.
5306
+ * Restricting this to `solo_agent_remote` dropped the nine models issue #19
5307
+ * was opened about.
5308
+ *
5309
+ * FUNCTION ATTRIBUTION (2026-10-20): a group is the authoritative statement
5310
+ * of which SOLO function a model belongs to — the accepted `config_name` is
5311
+ * isolated per function (glm-5.3 answers `solo_work_remote` and 4001s under
5312
+ * `solo_work_lite`). The wire join prefers its own `get_detail_param`
5313
+ * function and falls back to this one when the roster says nothing, which
5314
+ * restricted networks often do (measured: an empty `config_info_list` for
5315
+ * every chat function but `chat_v3`).
5316
+ *
5317
+ * ONLY CHAT-CAPABLE GROUPS ARE ATTRIBUTED. `solo_agent` and `builder_v3` are
5318
+ * IDE/Remote-agent rosters: asking `llm_utils_chat` with either answers
5319
+ * 4011/4001 (measured 2026-10-01 on a live credential, issue #19). A model
5320
+ * that appears ONLY there is still not uncallable, though — the SAME models
5321
+ * answered normally under `solo_work_remote` / `solo_work_lite` /
5322
+ * `solo_agent_remote` (measured the same day, see `TRAE_DIRECTORY_FUNCTIONS`
5323
+ * in solo.ts), and the default function is what has always reached them:
5324
+ * they are exactly the nine models issue #19's fix exposes (`gpt-5.5`,
5325
+ * `gpt-5.6-*`, `gpt-6-astra`, `glm-5.2`, `kimi-k2.7-code`,
5326
+ * `Dola-Seed-2.0-Code`, `deepseek-v4-flash-0731` — docs/ISSUE19_DIAGNOSIS.md).
5327
+ * Stamping `solo_agent` onto such a row makes every call 4011; DROPPING it
5328
+ * undoes issue #19 for exactly the restricted-network users this fallback
5329
+ * exists for. It therefore keeps NO function, the bridge falls back to the
5330
+ * default chat function exactly as before this change, and a refusal (if the
5331
+ * model really is unreachable) comes back as a readable upstream message
5332
+ * instead of a silent absence.
5333
+ *
5334
+ * CHAT GROUPS ARE WALKED IN `TRAE_DIRECTORY_FUNCTIONS` ORDER, not in the
5335
+ * request's `functions=` order. That list is the measured precedence of which
5336
+ * function serves a shared config, and it is the order the wire roster itself
5337
+ * collects in (`solo.ts`) — walking a different order would attribute the
5338
+ * same model differently depending on whether the roster answered, and the
5339
+ * difference is not cosmetic: `solo_agent_remote` sits after the two
5340
+ * `solo_work_*` functions because it is behind the Remote plan gate, so
5341
+ * preferring it can turn a free-tier model into a `1005 requires a paid
5342
+ * plan` refusal.
5343
+ *
5344
+ * A model appears in SEVERAL groups and the groups disagree about its
5345
+ * capacity, so dedupe keeps the MOST CAPABLE row rather than the first one
5346
+ * (issue #23). "First wins" assumed the group order was stable; measured
5347
+ * 2026-10-05 on a live CN credential it is not — five calls returned five
5348
+ * different orders — so the published window became a coin flip:
5349
+ * `Doubao-Seed-2.1-Pro` came back as `116000`/no-Max (`builder_v3`), or
5350
+ * `256000`/no-Max (`solo_work_*`), or `256000`/`1000000` (`chat_v3`,
5351
+ * `solo_agent*`) depending on which group answered first. The Max tier is
5352
+ * the one users enable in the IDE, and `applyContextBudgets` can only raise
5353
+ * a window to `maxContextWindow` — so losing that field silently capped
5354
+ * every configured 1M model at 256K or less, and DSH compressed far earlier
5355
+ * than the chosen budget implied.
5356
+ *
5357
+ * Ordering rule: a row advertising a Max tier beats one that does not, a
5358
+ * larger Max wins over a smaller one, and with no Max on either side the
5359
+ * wider dev window wins. Every row for one id carries the same
5360
+ * `multimodal` / `reasoning` / credit facts (verified the same day), so
5361
+ * choosing on capacity loses no capability. The result no longer depends on
5362
+ * the gateway's group order — and the FUNCTION does not either: the winning
5363
+ * row's capacity may come from a later group, but the separately-tracked
5364
+ * attribution is re-asserted on it.
5365
+ */
5366
+ collectModels(region, groups) {
4978
5367
  const byId = /* @__PURE__ */ new Map();
4979
- for (const group of groups) for (const raw of group.models ?? []) {
4980
- const model = parseTraeRemoteModel(raw);
4981
- if (model === void 0) continue;
4982
- byId.set(model.id, preferStrongerRow(model, byId.get(model.id)));
5368
+ const attributedFunction = /* @__PURE__ */ new Map();
5369
+ /**
5370
+ * The function a row may be stamped with: only a CHAT function. A non-chat
5371
+ * roster is not "the function this model belongs to" in any sense the chat
5372
+ * route can use (see the method note above), so it is not recorded at all.
5373
+ */
5374
+ const chatFunctions = new Set(TRAE_DIRECTORY_FUNCTIONS[region]);
5375
+ const attribution = (groupFunction) => groupFunction !== void 0 && chatFunctions.has(groupFunction) ? groupFunction : void 0;
5376
+ const addGroup = (groupFunction, raws) => {
5377
+ const fn = attribution(groupFunction);
5378
+ for (const raw of raws) {
5379
+ const model = parseTraeRemoteModel(raw, fn);
5380
+ if (model === void 0) continue;
5381
+ if (fn !== void 0 && !attributedFunction.has(model.id)) attributedFunction.set(model.id, fn);
5382
+ byId.set(model.id, preferStrongerRow(model, byId.get(model.id)));
5383
+ }
5384
+ };
5385
+ /**
5386
+ * Every group the gateway sent, bucketed BY FUNCTION NAME and kept as a
5387
+ * LIST.
5388
+ *
5389
+ * A name→group `Map` would keep only the LAST group of a name and silently
5390
+ * drop the models of every earlier one: nothing documents that the gateway
5391
+ * sends a function at most once, and the pre-change code unioned every
5392
+ * element of `data.list`, so a repeated name lost real models. Bucketing
5393
+ * keeps that union while the walk below still imposes a deterministic
5394
+ * order.
5395
+ */
5396
+ const byFunction = /* @__PURE__ */ new Map();
5397
+ for (const group of groups) {
5398
+ const key = group.function ?? "";
5399
+ const bucket = byFunction.get(key);
5400
+ if (bucket === void 0) byFunction.set(key, [group]);
5401
+ else bucket.push(group);
4983
5402
  }
4984
- const models = [...byId.values()];
4985
- if (models.length === 0) throw new Error("SOLO remote models response contained no models");
4986
- return models;
5403
+ for (const name of TRAE_DIRECTORY_FUNCTIONS[region]) for (const group of byFunction.get(name) ?? []) addGroup(name, group.models ?? []);
5404
+ for (const [name, bucket] of byFunction) {
5405
+ if (chatFunctions.has(name)) continue;
5406
+ for (const group of bucket) addGroup(name, group.models ?? []);
5407
+ }
5408
+ return [...byId.values()].map((model) => {
5409
+ const fn = attributedFunction.get(model.id);
5410
+ return fn === void 0 || fn === model.function ? model : {
5411
+ ...model,
5412
+ function: fn
5413
+ };
5414
+ });
4987
5415
  }
4988
5416
  };
4989
5417
  //#endregion
@@ -8490,7 +8918,8 @@ const modelConfig = z.object({
8490
8918
  creditMultiplier: z.number(),
8491
8919
  wireFunction: z.string(),
8492
8920
  wireConfigName: z.string(),
8493
- multimodal: z.boolean()
8921
+ multimodal: z.boolean(),
8922
+ activityDiscount: z.any()
8494
8923
  });
8495
8924
  /**
8496
8925
  * One panel's saved state. Panels are keyed `cn | enterprise | ai` — see
@@ -8756,7 +9185,10 @@ function apply(ctx, config) {
8756
9185
  ...panel === "enterprise" ? { route: "enterprise" } : {},
8757
9186
  log: (message, detail) => ctx.logger.warn(message, detail)
8758
9187
  });
8759
- const remoteCatalog = new TraeSoloRemoteCatalogClient({ credential: () => store.resolve() });
9188
+ const remoteCatalog = new TraeSoloRemoteCatalogClient({
9189
+ credential: () => store.resolve(),
9190
+ log: (message, detail) => ctx.logger.warn(message, detail)
9191
+ });
8760
9192
  const wireResolver = (displayId) => wire.byId.get(displayId) ?? wire.byName.get(displayId.trim().toLowerCase());
8761
9193
  /**
8762
9194
  * One client + bridge per provider id.
@@ -8909,18 +9341,24 @@ function apply(ctx, config) {
8909
9341
  },
8910
9342
  discoverModels: async (signal) => {
8911
9343
  const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
8912
- wire.callableKeys.clear();
9344
+ const callable = withChatCallableFunctions(merged, TRAE_DIRECTORY_FUNCTIONS[region]);
9345
+ const chatFunctions = TRAE_DIRECTORY_FUNCTIONS[region];
8913
9346
  for (const model of merged) {
9347
+ if (model.wireFunction === void 0 || chatFunctions.includes(model.wireFunction)) continue;
9348
+ ctx.logger.warn(`model directory row is pinned to a non-chat function and will be called through the default chat function instead: ${model.id} (pinned to function ${model.wireFunction})`);
9349
+ }
9350
+ wire.callableKeys.clear();
9351
+ for (const model of callable) {
8914
9352
  wire.callableKeys.add(model.id.trim().toLowerCase());
8915
9353
  wire.callableKeys.add(model.name.trim().toLowerCase());
8916
9354
  }
8917
- wire.resolved = merged.length > 0;
8918
- const wireTargets = wireTargetsOf(merged);
9355
+ wire.resolved = callable.length > 0;
9356
+ const wireTargets = wireTargetsOf(callable);
8919
9357
  wire.byId = wireTargets.byId;
8920
9358
  wire.byName = wireTargets.byName;
8921
9359
  latestDiscovery.length = 0;
8922
- latestDiscovery.push(...merged);
8923
- return merged;
9360
+ latestDiscovery.push(...callable);
9361
+ return callable;
8924
9362
  },
8925
9363
  /**
8926
9364
  * One-click availability test. The ids come from the card, so each one is