dsh-connect-trae 4.16.0 → 4.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,148 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.18.0 (2026-11-09)
4
+
5
+ ### 企业面板的发现层收录租户自配模型(`config_source: 2`)
6
+
7
+ 企业租户可以在 Trae 企业控制台配置自己的模型。Trae 自己的选择器能看到它们,插件的企业
8
+ 面板一直不列——这批行由企业侧 `get_detail_param` 返回(`config_source: 2` +
9
+ `usage: 'chat_completion'`),**不在**企业侧 Remote 目录里,所以只有「wire 提升」一条路
10
+ 能看见它们;而提升门槛只接受 `config_source === 1`,又拒绝「`display_name` 等于
11
+ `config_name`」的行——那恰好是租户配置模型的常见形态。两条门槛各自都足以挡掉它们。
12
+
13
+ **改法。** 提升门槛接受 `2`,且这类行允许用 `config_name` 当显示名。提升改为 **opt-in**
14
+ (`mergeTraeModelSources(..., { allowTenantConfigured })`),**只有企业面板传 `true`**:
15
+ 这些行计费落在租户池,放进消费面板等于让用户选到一个不属于自己计费面的模型。另外两条
16
+ 区域本来也取不到它们——它们的 wire 客户端钉在消费网关,那批行只出现在企业网关的名册里。
17
+
18
+ **这批模型现在调用不通,这是刻意不做的部分。** 租户配置的模型属于
19
+ `custom_openai_compatible`、客户端直连(`use_remote_service: false`、自带 base_url),
20
+ 插件没有对应传输通道,实测走 `llm_utils_chat` 返回 `4001`。本次只修发现层,通道另开
21
+ issue 讨论——不做「静默假设能调」这种事。真要选中时,拿到的是桥接层翻译过的可读错误
22
+ (`4001` → 该 SOLO function 不提供此模型),不是一次空白回复。
23
+
24
+ ### 企业版图片请求预算降到 8 MiB:网关只有 10 MiB,图文长任务不再 413
25
+
26
+ **症状。** 企业版跑带图的多步任务,跑到某一步整轮失败:
27
+
28
+ ```
29
+ 400: {"message":"<html>...<title>413 Request Entity Too Large</title>...openresty/1.27.1.2...",
30
+ "type":"client","code":"client"}
31
+ ```
32
+
33
+ 换模型、重试、少给几张图都没用——任务只要「长到某个长度」就必然挂。实测一次真实会话:
34
+ 15 张图、base64 合计 **12.32 MB**,在第 20 步被拒。
35
+
36
+ **根因。** `maxRequestImageBytes` 不是口味设置,它决定宿主要不要走「卸掉最老的图、重发」
37
+ 那条路:DSH 拿它和请求里**所有图片的 base64 总量**比较,超了就抛 `IMAGE_OFFLOAD_REQUIRED`,
38
+ 由 `dsh-compaction-image-offload` 写占位符重试。**这条链路只有在预算低于网关实测上限时才成立**;
39
+ 预算高于上限,宿主认为「装得下」,请求照发,被网关拍死在传输层——没有模型回复,无从恢复。
40
+
41
+ 而 `REQUEST_IMAGE_BUDGETS` 把**所有区域**都钉在宿主默认的 20 MiB,注释理由是「对齐宿主默认值」。
42
+ 那个理由对计数字段(`maxRequestImages` / `imageOffloadCountQuantum`,确是宿主契约)成立,
43
+ 对**字节上限**不成立:字节上限要对齐的是**网关**,而三个网关差 2.5 倍。
44
+
45
+ **实测(2026-11-09,未鉴权探针)。** openresty 在**鉴权之前**就按 body 体积拒绝,所以状态码本身就是
46
+ 上限,且不消耗任何凭据或积分:
47
+
48
+ | 网关 | 10,485,758 字节 | 10,485,762 字节 | 结论 |
49
+ | --- | --- | --- | --- |
50
+ | `console.enterprise.trae.cn`(`create_agent_task`) | `401` | **`413`** | 上限 10 MiB |
51
+ | `console.enterprise.trae.cn`(`llm_utils_chat`) | `401` | **`413`** | 上限 10 MiB |
52
+ | `trae-api-cn.mchost.guru` | `401`(测到 48 MiB) | — | 未触顶 |
53
+ | `coresg-normal.trae.ai` | `401`(测到 20 MiB) | — | 未触顶 |
54
+
55
+ **修复。** 只有企业版降到 **8 MiB**,另外两条区域保持 20 MiB:
56
+
57
+ | 区域 | 改前 | 改后 |
58
+ | --- | --- | --- |
59
+ | `trae-enterprise` | 20 MiB | **8 MiB** |
60
+ | `trae`(CN) | 20 MiB | 20 MiB |
61
+ | `trae-global`(国际) | 20 MiB | 20 MiB |
62
+
63
+ **为什么是 8 而不是 10。** 预算只管图片,请求还要带系统提示、工具声明和消息文本。8 MiB 留 2 MiB
64
+ 信封,而实测请求远没到这个量级(工具声明约 49 KB)。**为什么不干脆把三条都降下来**:CN 与国际
65
+ 网关实测 48 MiB / 20 MiB 都收,降它们只会白白卸掉用户本来能看到的图——那是静默的质量回退,
66
+ 不是修复。
67
+
68
+ 用那次失败会话的同一批图片跑宿主真实的卸载算法(`imageOffloadCountQuantum: 10`,超额按 10 的
69
+ 整数倍卸):
70
+
71
+ | | 改前(20 MiB) | 改后(8 MiB) |
72
+ | --- | --- | --- |
73
+ | 卸载是否触发 | 从不 | 第 13 张图时 |
74
+ | 卸载数量 | 0 / 15 | 5 / 15 |
75
+ | 实际发出 | ≈11.92 MiB → **413** | ≈7.99 MiB → 通过 |
76
+
77
+ **没选的做法。** ① 改宿主默认值——那是所有 provider 共享的契约,不该为一个网关让步。② 直接把
78
+ `imageOffloadCountQuantum` 调小——卸载粒度变细会增加重试轮次,而这次的问题是预算根本没触发
79
+ 卸载,与粒度无关。③ 只发占位符不重试——用户会永久失去图片内容,而重试路径本来就是为此设计的。
80
+
81
+ ### Tests
82
+
83
+ - 新增 `tests/adapter-image-budget.spec.ts`(3 例):企业版预算**严格小于**实测 10 MiB 上限;
84
+ CN 与国际**仍是**宿主默认 20 MiB;三条区域的计数上限都保持 50 / 10。
85
+ 第一例在修复前失败(`expected 20971520 to be less than 10485760`),修复后通过。
86
+ 另外两例钉住「不要把三条一起降下来」这条回退路径。
87
+ - **合并时补了一条下界**(≥ 5 MiB,即实测上限的一半):「小于 10 MiB」是个**任何更小的数**
88
+ 都满足的断言——改成 1 MiB 也照样绿,而那会把网关收得下的图白白卸掉,正是另外两条区域
89
+ 被保护着不许犯的那个错,只是从保守那一侧犯。实测把常量改成 `1_048_576` 会红
90
+ (`expected 1048576 to be greater than or equal to 5242880`)。
91
+ - `tests/catalog.spec.ts` 新增 4 例(租户自配模型):被提升(含 `config_name` 当显示名的形态);
92
+ 未 opt-in 时一行都不出(缺省与显式 `false` 两路);`usage` 非 `chat_completion` 仍被拒;
93
+ 空名与 `-` 仍被拒。变异验证:删掉 `allowTenantConfigured` 门控 → 第二例立刻变红。
94
+ - 全仓 **114 个文件 / 1469 条测试**通过。
95
+
96
+ ## 4.17.0 (2026-11-09)
97
+
98
+ ### 自动刷新改为可选项:整个插件一个节奏,默认 5 分钟
99
+
100
+ 改动前,插件对**同两条只读上游路由**用了**两个硬编码节奏**:卡片每 **60 秒**重读一次用量
101
+ 文档,输入框读数每 **5 分钟**一次。取哪个取决于你正好在看哪个界面,两个都改不了。而每次
102
+ 用量读取是**两次上游请求**,所以「正在看卡片」这件事本身就悄悄把后台流量翻了几倍。
103
+
104
+ 现在是一个字段 `refreshIntervalMinutes`,一处 UI(「设置 → Trae → **能力**」里的
105
+ 「自动刷新」),可选 **1 / 5 / 10 / 30 / 60 分钟**,**默认 5**。默认值选 5 是因为它正是
106
+ 输入框读数原本的节奏——升级不改变任何人的观感,而卡片的 60 秒被拉到同一个值上。
107
+
108
+ **为什么是「整个插件一个值」而不是每面板一个。** 它配的是插件的后台噪声,不是某个区域的
109
+ 数据:两处打的是同两条路由、同样的代价。按面板存会让同一块屏幕出现两个节奏,而这正是这次
110
+ 要消灭的东西。
111
+
112
+ **写读链路,以及为什么不是简单的 `setInterval` 换个数。**
113
+
114
+ | 环节 | 做法 | 不做会怎样 |
115
+ | --- | --- | --- |
116
+ | schema | `z.union([1,5,10,30,60])`,默认 5 | 手改文件写个 `7` 会被接受,得到一个没人选过的节奏 |
117
+ | 读取 | `refreshIntervalMinutesOf()`,非集合内一律回落默认 | 认不出的值让刷新**静默停止**,症状只是数字悄悄变旧 |
118
+ | 写入 | `writeSettingsField` 读回校验,比的是**解析后的分钟数** | 写入被接受但没落地时,选择器显示新值而定时器还是旧的 |
119
+ | 定时器 | 间隔进 effect 依赖,cleanup 先清旧定时器、中止在途请求 | 改一次间隔就多一个循环并跑,上游请求翻倍且界面看不出来 |
120
+ | 共享 | 卡片与输入框读数读**同一个** reader | 两个界面漂回两个节奏,正是本版要修的缺陷 |
121
+
122
+ 选项列表本身是**共享常量** `TRAE_REFRESH_INTERVAL_MINUTES`,schema 与选择器都从它渲染:
123
+ 两处各写一份字面量,就会出现「选择器给得出、写入拒绝」的选项。
124
+
125
+ **输入框读数补了一次设置订阅。** 它挂在 gate 之下,而 gate 订阅的是自己的两个开关——
126
+ 子组件不会因为父组件订阅了而重渲染。没有这次订阅,改了间隔要等到下一次无关渲染才生效。
127
+
128
+ ### Tests
129
+
130
+ - 新增 `tests/refresh-interval.spec.tsx`(16 例):读取器的默认/全集/回落/`{get()}` 实时引用;
131
+ schema 接受五个值、拒绝 `7` 与 `0`;选择器在「能力」分栏、显示存储值、只写一个字段、
132
+ 只读时拒写;**卡片按存储间隔读**(10 分钟时 1 分钟处零请求、10 分钟处恰好一次);
133
+ **改间隔会替换定时器**(旧 60 分钟 → 新 1 分钟,一分钟处恰好一次读);**输入框读数用同一
134
+ 字段**(1 分钟处就有读,不等旧的 5 分钟常量)。
135
+ - **变异验证 4 次全部被抓**:卡片定时器改回固定 60s → 1 例失败;输入框读数改回自己的常量
136
+ → 1 例失败;读取器接受任意数字 → 1 例失败;(另有一次改动由既有 `card-styles` 的
137
+ 「每个渲染出来的类都要有规则」守卫抓出,补了 `.dsm-trae-refresh-interval` 的样式)。
138
+ - 全仓 1446 → **1462**。
139
+
140
+ ### 顺带
141
+
142
+ `styles.ts` 的注释里曾写了一个反引号包住的类名——那个模板字符串会当场截断,整份样式表变成
143
+ 语法碎片,而 typecheck 与 build 都不报错。`card-styles` 的「顶层语句只能是变量声明」守卫
144
+ 当场抓到,改回普通文字。这个坑本仓库文档里已经记过一次(workbuddy 的 busy veil)。
145
+
3
146
  ## 4.16.0 (2026-11-08)
4
147
 
5
148
  ### 模型标签槽:按宿主的声明注册,为 `keyed` 做好准备
package/README.en.md CHANGED
@@ -44,6 +44,7 @@ In the account pool, open **More settings** and choose **Claude style** or **Def
44
44
  - **Three parallel providers** — the CN side registers as the `trae` provider (e.g. `DeepSeek-V4-Flash`, `DeepSeek-V4-Pro`), the enterprise side as `trae-enterprise`, and the international side as `trae-global` (the Gemini/GPT/MiniMax roster); **all three rosters appear in the DSH model picker simultaneously**: different sessions can each pick a side without interfering.
45
45
  - **Enterprise is a separate billing channel, not an alias for CN** — the same CN credential, when it belongs to a `tob_online` tenant, is served by **two** gateways: the consumer pool (billed against personal credits) and the tenant pool (billed against the enterprise allowance). Same model, two payers, so **you** choose who pays; the plugin never guesses. `trae` never touches the enterprise gateway, and `trae-enterprise` **never falls back** — a refusal is reported as a refusal instead of quietly re-running on personal credits. Enterprise is **off by default**: a personal-only account cannot use the tenant pool, so shipping it enabled would mean handing everyone a provider that fails on first use.
46
46
  - **Any panel can be switched off on its own** — each tab carries its own checkbox (CN and international checked by default, enterprise not): unchecking **withdraws that provider from the DSH model picker entirely** (not merely hiding the tab) and stops all of its requests; checking it again restores it with the account, directory, picks, and context budgets intact. If you never use the international side, just uncheck it.
47
+ - **Enterprise models configured by the tenant now show up** — a tenant can configure models of its own in the Trae enterprise console. Those rows (`config_source: 2`) are absent from the enterprise-side Remote directory, so the plugin used to hide them entirely; they now appear on the enterprise panel and in the DSH picker. **They are not callable yet**: those models are client-direct (`use_remote_service: false`, carrying their own `base_url`) and the plugin has no transport for that channel, so selecting one fails honestly (`4001` → this SOLO function does not serve it) instead of failing silently or pretending to answer. They bill the tenant pool, which is why they appear **only on the enterprise panel**; CN and international are untouched — those two read consumer-gateway rosters to begin with.
47
48
  - **Tabbed settings card** — the card's top carries a "Domestic / Enterprise / Global" tab bar; each tab holds its own account picker, usage overview, and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions. CN and enterprise share one CN credential pool but keep **separate account bindings and model picks**: the two rosters differ (`DeepSeek-V4.1-Flash` is listed only on the enterprise pool), so picks never overwrite each other.
48
49
  - **The card then splits into "Account / Models / Diagnostics"** (learned from the sibling `dsh-connect-workbuddy`). The region row above is the **scope** — which provider this whole card is about — while these are sections of that one card: Account covers who serves, what is left, the check-in and the pool; Models covers which models to tick, image input, the context budget and Save; Diagnostics read-only lists the panel, the provider id, the serving account, both token deadlines, the catalog/enabled model counts, whether the pool and the check-in are offered, and the **verbatim** text of this panel's last reported failure. **As of 4.4.7 the diagnostics section carries one more line, "quarantined copies"**: a credential copy that could not be read — and was therefore renamed aside instead of being erased — is named there, together with how many accounts it still holds. "Where did the account I remember go?" is now answered by the card instead of by browsing `~/.dsh` for a file name; the signed-out screen shows the same notice, because that is the screen a user lands on after an account disappears. The split exists because the two halves **commit differently**: an account change lands on the click, a catalog change is a draft until Save — so a single long column put the save button three screens away from what it saves. Switching sections never refetches, never drops a half-filled model draft, and never rebuilds the pool's log. A signed-out card renders no section strip, so its one screen of instructions cannot be hidden behind a tab the user cannot see. **The Models section lays every model out at once, with no inner scrollbar** — it is not a little window scrolling inside a page that already scrolls; the card's own page is the only scroll.
49
50
  - **Multiplier in the model name** — model names show the credit multiplier in Trae's own menu format (e.g. `GLM-5.2 · x0.79`), updated with each directory refresh.
@@ -61,11 +62,12 @@ In the account pool, open **More settings** and choose **Claude style** or **Def
61
62
  > `4011` is a **rate limit**, not "this model is unavailable" (measured for issue #33): an exhausted balance reports a different code, `4008`, so "I still have plenty of credits" and "I got a 4011" are both true at once. A throttled model answers again after a short wait — do not delete it.
62
63
  - **One-click test of all models** — the same probe applied to **every visible model** of the current region, not just the enabled ones. Use it when you want to know whether the models you have switched off could still answer — after a plan change, or after Trae reshuffles its lineup.
63
64
  - **Daily check-in claiming (per account)** — on the CN card **every account row carries its own check-in button**, with the day's reward written on it (`Check in +150`). A row reads **Claimed** and disables itself once *that account* has been paid, and reads **Device used** and disables itself when *this machine* has already spent today's slot through some other account — the two are reported honestly, and the second is never dressed up as "claimed". This is the plugin's **only** operation that changes account state, and it runs only when you click; one click targets that one row's account and sends exactly one request. There is deliberately **no "claim for every account"**: Trae allows **one check-in per device per day** (switching accounts does not reset the slot), so a sweep could only manufacture a row of false failures. The international side has no check-in activity, so no button is shown there.
64
- - **Composer readout** — the composer tool row (between "Expert" and the model selector) shows a clickable readout, refreshing every 5 minutes. **Both regions show it, but they do not show the same quantity, and that is the data's doing**: the CN side is credit-based, so it reads "Trae CN · <general credits>"; the international side is subscription-based and its usage document carries no credit field at all, so it reads "Trae Global · <plan>" (e.g. `Pro` / `Free`). The whole readout is clickable and opens a panel showing a TABLE: one row per account in the region — general credits on CN, plan on international — where clicking a row SWITCHES accounts (through the same merged `accounts` write the card uses, so the other region's selection survives). The panel's top right carries **two controls, Refresh and Settings** (as of 4.9.0 the Settings button targets the settings panel; an interim revision removed it on the belief that this was impossible, which was wrong — the host publishes no id-targeted *service*, but the settings shell pins its store seat to the registration so `slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` is a real route, and the host's own tests drive the dialog exactly that way). While Refresh runs its icon turns: the batch is serial, so the slowest account decides when it stops. **Refresh** asks every account checked into this panel's account pool to answer once (through the pool's `action=test` route — the same batch the pool section's own button runs) and reports the outcome in the panel's foot: `Pool tested · 2 of 2 answered`. **Two things about that press the label cannot say, so both live in the tooltip and in the foot line**: it tests the **pool's checked-in members**, not every account in the table you are looking at, and it is the **only press in this plugin that spends credits** (one minimal ping per member) — a bare "Refresh" reads as free, so the tooltip is worth reading. To change anything, open **Settings → Trae** — the plugin's only configuration surface. The balance needs no refresh button of its own — it re-reads when the panel opens, on every account switch, and every five minutes, which is why that button was replaced rather than joined by another. It appears **only while the session's selected model belongs to this plugin**, and renders nothing for any other provider — which is what keeps two connector plugins from competing for one shared row (the sidebar foot is a shared root row; the composer row is per-session and provider-scoped).
65
+ - **Composer readout** — the composer tool row (between "Expert" and the model selector) shows a clickable readout, refreshing every 5 minutes by default (the interval is configurable — see the next bullet). **Both regions show it, but they do not show the same quantity, and that is the data's doing**: the CN side is credit-based, so it reads "Trae CN · <general credits>"; the international side is subscription-based and its usage document carries no credit field at all, so it reads "Trae Global · <plan>" (e.g. `Pro` / `Free`). The whole readout is clickable and opens a panel showing a TABLE: one row per account in the region — general credits on CN, plan on international — where clicking a row SWITCHES accounts (through the same merged `accounts` write the card uses, so the other region's selection survives). The panel's top right carries **two controls, Refresh and Settings** (as of 4.9.0 the Settings button targets the settings panel; an interim revision removed it on the belief that this was impossible, which was wrong — the host publishes no id-targeted *service*, but the settings shell pins its store seat to the registration so `slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` is a real route, and the host's own tests drive the dialog exactly that way). While Refresh runs its icon turns: the batch is serial, so the slowest account decides when it stops. **Refresh** asks every account checked into this panel's account pool to answer once (through the pool's `action=test` route — the same batch the pool section's own button runs) and reports the outcome in the panel's foot: `Pool tested · 2 of 2 answered`. **Two things about that press the label cannot say, so both live in the tooltip and in the foot line**: it tests the **pool's checked-in members**, not every account in the table you are looking at, and it is the **only press in this plugin that spends credits** (one minimal ping per member) — a bare "Refresh" reads as free, so the tooltip is worth reading. To change anything, open **Settings → Trae** — the plugin's only configuration surface. The balance needs no refresh button of its own — it re-reads when the panel opens, on every account switch, and on every refresh interval, which is why that button was replaced rather than joined by another. It appears **only while the session's selected model belongs to this plugin**, and renders nothing for any other provider — which is what keeps two connector plugins from competing for one shared row (the sidebar foot is a shared root row; the composer row is per-session and provider-scoped).
65
66
 
66
67
  <img src="docs/assets/dsh-connect-trae-composer-credits.png" width="560" alt="Trae credits panel: a table of accounts with their general balance, and the composer readout below" />
67
68
 
68
69
  **Where that readout sits is a choice** (learned from the sibling `dsh-connect-workbuddy` issue #62). The default is the **composer toolbar** (the position described above); if another plugin occupies it and the readout does not appear, move it **below the input box** — the row carrying the round / step / token statistics, where the readout is APPENDED after the shipped figures rather than replacing them. The seat is set **per panel** (all three are stored separately), in the card's settings block next to the "show credits in the composer row" checkbox. Why a second seat exists at all: the composer toolbar is a **shared list slot** that the shell's own controls and every other plugin register into, and a shared slot **cannot promise that every occupant is visible** — the report behind that issue is a readout that vanished entirely once a third-party plugin was installed (this plugin has been squeezed out of a shared row once before too, down to a clipped "Tr" at the sidebar foot).
70
+ - **The automatic-refresh interval is configurable** — "refresh every" under **Settings → Trae → Capabilities** decides how often the card and the composer readout re-read usage by themselves: **1 / 5 / 10 / 30 / 60 minutes**, **5 by default**. **One value for the whole plugin**, not one per panel: both surfaces hit the same two read-only routes, so a per-panel copy would make one screen refresh at two cadences. A change takes effect **immediately** — the old timer is cleared and its in-flight read aborted before a new one starts at the new period, so a change cannot leave two loops running. **A shorter interval means more requests**: each refresh is two read-only upstream requests and nothing here spends credits. Before this setting the card was hardcoded to 60 seconds while the composer readout was hardcoded to five minutes — two cadences for the same data, decided by which surface you happened to be looking at. Both now read this one field.
69
71
 
70
72
  - **Optional: put Trae in the sidebar** (learned from the sibling `dsh-connect-workbuddy` PR #188, and **off by default** here too). Ticking "Show Trae in the sidebar" in the card's settings block adds a Trae row to the DSH sidebar; it opens the **full-page version of this same card** — same region tabs, same account / models / diagnostics sections, minus the card frame, with a title, a description and "Back to conversation" at the top. **Why it is off by default**: the sidebar's global rows are the host's own pages, and this row claims a seat **every installed bundle shares** while the settings page already reaches the same content — a nav row nobody asked for re-arranges a surface this plugin does not own. **It takes effect on the click** with no Save: writing `sidebarPanel` registers or disposes the row in the same second (it is not a component rendering null — that would leave a blank but still clickable button), and switching it off while the page is open sends the centre column back to the conversation by itself. The page's back control goes through the host's `layout.selectPanel(null)`; on a host without that service the control is **omitted rather than drawn dead**.
71
73
  <img src="docs/assets/dsh-connect-trae-sidebar-page.png" width="720" alt="The sidebar page: back control, title and description at the top, then the same card body below" />
package/README.md CHANGED
@@ -56,6 +56,7 @@
56
56
  - **文生图(`trae_draw_image`)** —— 把登录账号的 Trae 文生图能力接进会话:模型画一张图,插件把**图片字节一起取回来**、写进会话目录再显示给你。**默认关闭、按面板单独开**——生图真实消耗积分(实测 CN 个人版 `1920x1920` 一张约 8.8),所以它和识图那种「让不让看图」的开关不同,只能由你点头。协议、尺寸边界、取图那两步,以及为什么「取图」那条 GET 其实是又一次生成,见下方「[文生图](#文生图trae_draw_image)」。
57
57
  - **国内国际双区域自动识别** —— 自动发现 Trae CN / TRAE SOLO CN / Trae / TRAE SOLO 四个本地安装的登录账号;区域由凭证自带的 `userRegion` 声明自动判定(host 后缀与 edition 标签兜底),无需手动指定。**美国区(`userRegion: "US"` / `*.traeapi.us`)目前「识别但不接入」**:插件不会把 US 凭据当成新加坡区发出去,而会在账号面板明确写「美国区(US)暂不支持」并点名账号,同时说明重新登录不会改变结果(issue #66)。
58
58
  - **目录与勾选按面板隔离** —— 三个面板各一套模型目录、勾选、图片开关与上下文预算,各自读各自的槽位。名录从**该面板所钉的那条网关**读取——原本钉在企业池的客户端去读消费池名录,会永远发现不了自己池的独占模型。
59
+ - **企业面板会列出租户自配的模型** —— 企业租户可以在 Trae 企业控制台配置自己的模型,这类行(`config_source: 2`)不在企业侧的 Remote 目录里,过去在插件里完全看不见,现在会出现在企业面板与 DSH 选择器里。**它们目前还调不通**:这批模型是客户端直连(`use_remote_service: false`、自带 `base_url`),插件没有对应传输通道,选中后调用会如实报错(`4001` → 该 SOLO function 不提供此模型),不会静默失败或假装成功。计费属租户池,所以**只在企业面板出现**,国内版与国际版不受影响——那两个面板本来也只读消费网关的名录。
59
60
  - **识图能力跟随 Trae 自身的标注** —— Trae 把某个模型标为 `multimodal` 时,插件直接按多模态对外声明,无需你在卡片上逐一手勾(issue #16)。判据是「**你手动勾选的 ∪ Trae 标注多模态的**」:Trae 明确标 `false` 或未标注的模型保持纯文本,你可以照旧手动为它们打开图片输入。卡片上被自动开启的那一行显示为已勾选且只读,并说明原因。详见下方「图片输入(识图)」。
60
61
  - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。**你的选择会被记住并长期生效**:插件不会因为某个账号额度用尽、或检测到另一个可用账号就自作主张替你切换(换账号意味着换人计费)——唯一的例外是你**亲自打开**的账号池(见下一条),那是一个显式选择,而不是插件的自作主张。所以当绑定的账号额度耗尽时,卡片会**明确告诉你**,并把本机其他还有额度的账号连同其可用积分数列出来,由你一键切换——而不是让面板一直空着、也不自动把你切走。「刷新账号与 Token」每次都会回报结果(找到几个账号、当前仍绑定谁),不会再「点了没反应」。下拉里显示的是产品名(`Trae` / `TRAE SOLO` / 国际版),便于分辨凭据来自哪个安装。
61
62
  - **账号导入 / 导出** —— 不用再为了用第二个账号而把凭据手工写进默认路径、顶掉上一个。在卡片账号区的「导入 / 导出账号」里:**导出**勾选若干账号下载一份 JSON;**导入**选择文件后先看**脱敏预览**(只显示账号标识与「是否有 Token」,不含凭据本身)再勾选导入。文件格式与同族 `dsh-connect-workbuddy` **互通**,两边导出的文件可以互相导入。导入按账号去重(同一账号再导入即覆盖更新),**认不出所属区域或因缺 Token 的记录会被跳过并计数**——国内版与国际版是两条并行计费通道,跨区导入会导致跨区计费,所以这里只认同能**正向识别**的区域(企业版共用 CN 凭据池,不构成第三条导入通道)。导出文件里是**有效 Token,请当作密码保管**。(issue #37)
@@ -66,11 +67,12 @@
66
67
  > `4011` 是**限流**不是「模型不可用」(issue #33 实测):它额度耗尽走的是另一个码 `4008`,所以「额度还有很多却报 4011」两句话可以同时成立。被限流的模型**等你一会儿再试**即可,不要删。
67
68
  - **一键测试所有模型** —— 同一套探测,作用于当前 region 的**全部可见模型**(不限于已启用的)。想知道「没启用的那些到底还能不能用」时用它——比如套餐降级后,或 Trae 调整了模型阵容之后。
68
69
  - **每日签到领取(逐账号)** —— 国内版**每个账号行各有一个签到按钮**,当日奖励直接写在按钮上(`签到 +150`);该账号当天已领显示「已领取」并禁用,**本机今日名额已被别的账号用掉**时显示「本机已签」并禁用——如实区分「这个账号领了」和「这台设备领过了」,绝不把后者说成「已领取」。这是插件里**唯一会改变账号状态**的操作,由你主动点击触发;一次点击只针对该行账号、只发一次请求。Trae 的签到按「**每台设备每天一次**」计(换账号不会重置当天名额),所以逐账号签到始终是主路径。账号池里另有一个「全部签到」,它**不是**把同一件事盲目重复:一旦上游回 `9095`(本机今日已领),整批立刻停止并把剩余账号如实标成「本机已签」,产出的是真相而不是一排假失败。国际版没有签到活动,因此不显示该按钮。
69
- - **输入框读数** —— 在输入框工具栏(「专家」与模型选择器之间)显示一块可点击读数,5 分钟自动刷新。**两个区域都显示,但显示的东西不同,这是数据决定的**:国内版是积分制,显示「Trae CN · <通用积分>」;国际版是订阅制,它的用量文档里根本没有积分字段,所以显示「Trae 国际版 · <套餐>」(如 `Pro` / `Free`)。**整块可点击**,弹出浮层显示一张**表格**:本区域**每个账号一行**,国内版列**通用积分**、国际版列**套餐**,**点行即可切换账号**(写入与卡片相同的 `accounts` 字段、合并时不丢掉另一个区域的选择)。浮层右上角是**「刷新」与「设置」两个控件**(4.9.0 起「设置」改指设置面板;中途曾以为做不到而删掉,是错的——宿主确实没有 id 定向的服务,但设置面板把自己的 store 座位钉在注册声明上,`slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` 是真实可用的路径,宿主自己的测试就是这么驱动的)。按「刷新」时图标会转,直到这批探测结束——它是串行跑的,最慢的账号决定什么时候结束。**「刷新」**把账号池里勾选的成员逐个问一遍「能不能应答」(走账号池那条 `action=test` 路由,与账号池分栏里的测试是同一个动作),结果直接报在浮层底部:`账号池已测 · 2 of 2 可应答`。**这个按钮有两件事标签说不出来,都写在悬停提示与结果行里**:它测的是**账号池里勾选的成员**,不是你眼前这张表里的每个账号;而且它是**插件里唯一消耗额度**的按钮(每个成员一条最小 `ping`)——一个光秃秃的「刷新」读起来是免费的,所以工具提示必须留着看。要改配置就去「设置 → Trae」:那是本插件唯一一处配置界面。积分本身不需要刷新按钮:**打开浮层、切换账号、每 5 分钟**都会重新读一次,所以那个按钮被换掉而不是并排加进来。**只在当前会话选中的模型属于本插件时出现**——选其它供应商的模型时整块不渲染。这样两个连接器插件不会争同一个位置(侧边栏底部是共享的根级行,输入框工具栏是按会话、按供应商的)。
70
+ - **输入框读数** —— 在输入框工具栏(「专家」与模型选择器之间)显示一块可点击读数,默认每 5 分钟自动刷新(间隔可调,见下一条)。**两个区域都显示,但显示的东西不同,这是数据决定的**:国内版是积分制,显示「Trae CN · <通用积分>」;国际版是订阅制,它的用量文档里根本没有积分字段,所以显示「Trae 国际版 · <套餐>」(如 `Pro` / `Free`)。**整块可点击**,弹出浮层显示一张**表格**:本区域**每个账号一行**,国内版列**通用积分**、国际版列**套餐**,**点行即可切换账号**(写入与卡片相同的 `accounts` 字段、合并时不丢掉另一个区域的选择)。浮层右上角是**「刷新」与「设置」两个控件**(4.9.0 起「设置」改指设置面板;中途曾以为做不到而删掉,是错的——宿主确实没有 id 定向的服务,但设置面板把自己的 store 座位钉在注册声明上,`slots.entries('sidebar.settings')[0].store.create().actions.openSection(id)` 是真实可用的路径,宿主自己的测试就是这么驱动的)。按「刷新」时图标会转,直到这批探测结束——它是串行跑的,最慢的账号决定什么时候结束。**「刷新」**把账号池里勾选的成员逐个问一遍「能不能应答」(走账号池那条 `action=test` 路由,与账号池分栏里的测试是同一个动作),结果直接报在浮层底部:`账号池已测 · 2 of 2 可应答`。**这个按钮有两件事标签说不出来,都写在悬停提示与结果行里**:它测的是**账号池里勾选的成员**,不是你眼前这张表里的每个账号;而且它是**插件里唯一消耗额度**的按钮(每个成员一条最小 `ping`)——一个光秃秃的「刷新」读起来是免费的,所以工具提示必须留着看。要改配置就去「设置 → Trae」:那是本插件唯一一处配置界面。积分本身不需要刷新按钮:**打开浮层、切换账号、每个刷新间隔**都会重新读一次,所以那个按钮被换掉而不是并排加进来。**只在当前会话选中的模型属于本插件时出现**——选其它供应商的模型时整块不渲染。这样两个连接器插件不会争同一个位置(侧边栏底部是共享的根级行,输入框工具栏是按会话、按供应商的)。
70
71
 
71
72
  <img src="docs/assets/dsh-connect-trae-composer-credits.png" width="560" alt="Trae credits panel: a table of accounts with their general balance, and the composer readout below" />
72
73
 
73
74
  **读数显示在哪里可以选**(学自同族 `dsh-connect-workbuddy` issue #62)。默认在**输入框工具栏**(即上面说的位置);如果那里被别的插件占了、读数不显示,改成**输入框下方**——那是「轮次 / 步数 / token」统计所在的那一行,读数是**追加**在官方统计之后,不会顶掉它们。位置**按面板分别设置**(三个面板各存各的),在卡片「设置」区紧挨「在输入框工具栏显示积分读数」那个勾选框。之所以要有第二个位置:输入框工具栏是**共享的 list 插槽**,外壳自己的控件和其他插件都注册在那里,而共享插槽**无法保证每个占用者都可见**——同族的报告者装了第三方插件后读数就整个消失了(本插件早年放在侧边栏底部时也被挤成过一个「Tr」的残片)。
75
+ - **自动刷新间隔可调** —— 「设置 → Trae → 能力」里的「自动刷新」决定卡片与输入框读数各自多久自己重新读一次用量,可选 **1 / 5 / 10 / 30 / 60 分钟**,默认 **5 分钟**。**整个插件共用一个值**(不是每个面板一个):两处打的是同两条只读路由,分开设置只会让同一块屏幕出现两个节奏。改动**即时生效**——旧定时器先被清掉、在途请求被中止,再按新间隔重建,所以不会留下两个循环并跑。**间隔越短请求越频繁**:每次刷新是两次只读上游请求,这里不消耗积分。这个设置之前,卡片硬编码 60 秒、输入框读数硬编码 5 分钟——同一份数据两个节奏,取决于你正好在看哪个界面;现在统一到这一个字段。
74
76
 
75
77
  - **可选:把 Trae 放进侧边栏**(学自同族 `dsh-connect-workbuddy` 的 PR #188,同样**默认关闭**)。在卡片「设置」区勾上「在侧边栏显示 Trae」,DSH 侧边栏就多一个 Trae 行,点开是**这张卡片的整页版**:同样的地区页签、同样的账号 / 模型 / 诊断分栏,只是没有卡片边框,顶部是标题、说明和「返回会话」。**默认关闭的理由**:侧边栏的全局行是宿主自己的页面,这一行占的是**所有已安装插件共用**的座位,而「设置 → Trae」本来就能看到同样的内容——一行没人要求的导航会改变一个插件并不拥有的界面。**点一下即生效**,不用保存:开关写入 `sidebarPanel` 后,注册与注销在同一秒发生(不是把组件渲染成 null——那会留下一个空白却仍可点的按钮);页面开着时关掉它,中心栏会自己回到会话。整页的「返回会话」走宿主的 `layout.selectPanel(null)`,宿主没这个服务时**不画**这个按钮,而不是画一个点不动的。
76
78
  <img src="docs/assets/dsh-connect-trae-sidebar-page.png" width="720" alt="Trae 侧边栏整页版:顶部是返回会话、标题与说明,下面是同一张卡片的地区页签和账号栏" />
package/lib/client.js CHANGED
@@ -574,6 +574,45 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
574
574
  });
575
575
  }
576
576
  /**
577
+ * The automatic-refresh cadences this plugin offers, in MINUTES.
578
+ *
579
+ * The list is the setting's whole value domain: the schema validates against
580
+ * exactly these, so a hand-edited `7` is refused at write time instead of
581
+ * silently producing a cadence nobody chose. `1` is the floor because every
582
+ * usage read costs two upstream requests and a tighter loop would turn a
583
+ * background refresh into traffic the user never asked for.
584
+ */
585
+ const TRAE_REFRESH_INTERVAL_MINUTES = [
586
+ 1,
587
+ 5,
588
+ 10,
589
+ 30,
590
+ 60
591
+ ];
592
+ const REFRESH_INTERVAL_SET = new Set(TRAE_REFRESH_INTERVAL_MINUTES);
593
+ /**
594
+ * How often the plugin refreshes on its own, in minutes.
595
+ *
596
+ * Anything that is not one of {@link TRAE_REFRESH_INTERVAL_MINUTES} reads as
597
+ * the default — absent (a profile written before the setting existed), a
598
+ * hand-edited `7`, a string `'5'`, or a live reference that resolves to none of
599
+ * those. Falling back rather than refusing to refresh is deliberate: a readout
600
+ * that stops updating because of an unrecognized value is the failure this
601
+ * setting must never introduce, and the schema already refuses such a value on
602
+ * the write path.
603
+ *
604
+ * `value` is the whole settings section (this field is plugin-wide, not
605
+ * per-region — see `Config.refreshIntervalMinutes`).
606
+ */
607
+ function refreshIntervalMinutesOf(value) {
608
+ const stored = unwrapVolatileDeep(value)?.refreshIntervalMinutes;
609
+ return typeof stored === "number" && REFRESH_INTERVAL_SET.has(stored) ? stored : 5;
610
+ }
611
+ /** The same cadence as a millisecond period, for `setInterval`. */
612
+ function refreshIntervalMsOf(value) {
613
+ return refreshIntervalMinutesOf(value) * 6e4;
614
+ }
615
+ /**
577
616
  * One panel's saved pool PREFERENCES, as the card's settings block edits them.
578
617
  *
579
618
  * Read from the settings document rather than from the usage answer for the same
@@ -1403,6 +1442,19 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
1403
1442
  question and sits beside the words that ask it. */
1404
1443
  .dsm-trae-credit-position{flex:none;max-width:none;margin-top:0;padding:3px 8px;font-size:12px;line-height:16px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);cursor:pointer}
1405
1444
  .dsm-trae-credit-position:disabled{cursor:default}
1445
+ /* The automatic-refresh picker, a sibling of the seat picker above and styled
1446
+ to match it — the two answer the same kind of question ("how this readout
1447
+ behaves") and sit on consecutive lines, so a second visual idiom would read
1448
+ as an unrelated control.
1449
+
1450
+ Its OWN class, deliberately NOT the seat picker's: a test that selects the
1451
+ seat picker by class must not start finding the cadence picker the moment
1452
+ this row is added, which is exactly the failure the seat picker's own comment
1453
+ records. The row borrows the checkbox column's padding for the same reason
1454
+ its sibling does. */
1455
+ .dsm-trae-refresh-interval-pref{align-items:flex-start;padding-left:25px}
1456
+ .dsm-trae-refresh-interval{flex:none;max-width:none;margin-top:0;padding:3px 8px;font-size:12px;line-height:16px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);cursor:pointer}
1457
+ .dsm-trae-refresh-interval:disabled{cursor:default}
1406
1458
  /* The card's checkboxes are the platform's own, styled only with
1407
1459
  accent-color 鈥?the SAME idiom .dsm-trae-mainui-switch-label above already
1408
1460
  uses for the composer-line switch, and the one the sibling
@@ -4409,7 +4461,7 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
4409
4461
  * is precisely why this section has no 「保存」 of its own — the model catalog,
4410
4462
  * which does, lives in its own section.
4411
4463
  */
4412
- function TraeAccountPanel({ t, settingsScope, panel, status, statusLabel, credentialRegionIsAi, regionOn, showPointsInMainUi, onToggleShowPoints, creditPosition, onCreditPositionChange, sidebarPanelOn, sidebarPanelBusy, onToggleSidebarPanel, savingPreferences, creditsLabelId, creditsHintId, rows, busy, rescanNote, onRescan, switchingAccount, onSwitchAccount, onClaim, onTransferChanged, poolDetails, poolClaims, models = [], deviceClaimed = false, onPoolChanged = () => {}, onPoolRefresh = () => {}, creditsUnavailable, creditsExpiry, alternatives, onSwitchToAlternative, section = "all" }) {
4464
+ function TraeAccountPanel({ t, settingsScope, panel, status, statusLabel, credentialRegionIsAi, regionOn, showPointsInMainUi, onToggleShowPoints, creditPosition, onCreditPositionChange, refreshIntervalMinutes, onRefreshIntervalChange, sidebarPanelOn, sidebarPanelBusy, onToggleSidebarPanel, savingPreferences, creditsLabelId, creditsHintId, rows, busy, rescanNote, onRescan, switchingAccount, onSwitchAccount, onClaim, onTransferChanged, poolDetails, poolClaims, models = [], deviceClaimed = false, onPoolChanged = () => {}, onPoolRefresh = () => {}, creditsUnavailable, creditsExpiry, alternatives, onSwitchToAlternative, section = "all" }) {
4413
4465
  const writable = settingsScope?.getSnapshot().writable === true;
4414
4466
  const signedIn = status.status === "signed-in";
4415
4467
  const accounts = signedIn ? status.accounts : [];
@@ -4474,6 +4526,37 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
4474
4526
  })]
4475
4527
  })
4476
4528
  }),
4529
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
4530
+ className: "dsm-trae-pref dsm-trae-refresh-interval-pref",
4531
+ title: t("row.refreshIntervalHint"),
4532
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4533
+ className: "dsm-trae-pref-copy",
4534
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4535
+ className: "dsm-trae-pref-lead",
4536
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4537
+ className: "dsm-trae-pref-label",
4538
+ children: t("row.refreshIntervalLabel")
4539
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
4540
+ className: "dsm-trae-refresh-interval",
4541
+ value: String(refreshIntervalMinutes),
4542
+ disabled: !writable || savingPreferences,
4543
+ "aria-label": t("row.refreshIntervalAria"),
4544
+ onChange: (event) => {
4545
+ const picked = Number(event.currentTarget.value);
4546
+ const match = TRAE_REFRESH_INTERVAL_MINUTES.find((each) => each === picked);
4547
+ if (match !== void 0) onRefreshIntervalChange(match);
4548
+ },
4549
+ children: TRAE_REFRESH_INTERVAL_MINUTES.map((minutes) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4550
+ value: String(minutes),
4551
+ children: t("row.refreshIntervalOption", { minutes: String(minutes) })
4552
+ }, minutes))
4553
+ })]
4554
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4555
+ className: "dsm-trae-pref-hint",
4556
+ children: t("row.refreshIntervalHint")
4557
+ })]
4558
+ })
4559
+ }),
4477
4560
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4478
4561
  className: "dsm-trae-pref",
4479
4562
  title: t("row.sidebarPanelHint"),
@@ -4881,7 +4964,16 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
4881
4964
  }
4882
4965
  return `HTTP ${status}`;
4883
4966
  }
4884
- const POLL_INTERVAL_MS = 6e4;
4967
+ /**
4968
+ * The card's own poll period is NOT a constant: the cadence is the user's
4969
+ * (`Config.refreshIntervalMinutes`, default 5 minutes), read through
4970
+ * {@link refreshIntervalMsOf} so the card and the composer readout cannot drift
4971
+ * apart. It used to be a fixed 60_000 here while the composer readout polled
4972
+ * every five minutes — two cadences for the same two upstream routes, decided
4973
+ * by which surface you happened to be looking at. The floor of the offered
4974
+ * values is one minute; every read is two read-only upstream calls, which is
4975
+ * why the setting does not go below that.
4976
+ */
4885
4977
  const TRAE_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-trae";
4886
4978
  /**
4887
4979
  * Write one settings field and confirm the value actually landed.
@@ -5248,7 +5340,20 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5248
5340
  * local state: a write made elsewhere (the DSH settings form, another window)
5249
5341
  * then shows up here instead of being shadowed by a stale copy.
5250
5342
  */
5251
- const committedSettings = { showPointsInMainUi: unwrapVolatileDeep(settingsScope?.getSnapshot().value)?.showPointsInMainUi === true };
5343
+ const committedSettings = {
5344
+ showPointsInMainUi: unwrapVolatileDeep(settingsScope?.getSnapshot().value)?.showPointsInMainUi === true,
5345
+ /**
5346
+ * The automatic-refresh cadence (`Config.refreshIntervalMinutes`).
5347
+ *
5348
+ * Read through the SHARED reader rather than off the document here: the
5349
+ * composer readout reads the same field through the same function, and the
5350
+ * two surfaces disagreeing about the cadence is exactly what a second
5351
+ * interpretation would produce. It also owns the fallback for a document
5352
+ * that never went through the schema (a hand-edited file), so the card
5353
+ * cannot end up on a cadence the write path would have refused.
5354
+ */
5355
+ refreshIntervalMinutes: refreshIntervalMinutesOf(settingsScope?.getSnapshot().value)
5356
+ };
5252
5357
  /**
5253
5358
  * Ids for the block's label/hint wiring. `useId`, not constants: DSH can mount
5254
5359
  * this card twice at once (the settings ROW and the expanded PAGE), and two
@@ -5260,11 +5365,25 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5260
5365
  * over it. Reading BOTH sources here is what lets the write above stay simple —
5261
5366
  * it never has to write into component state that a Host refresh would fight.
5262
5367
  */
5263
- const settingsEdits = { showPointsInMainUi: optimisticSettings.current.showPointsInMainUi ?? committedSettings.showPointsInMainUi };
5368
+ const settingsEdits = {
5369
+ showPointsInMainUi: optimisticSettings.current.showPointsInMainUi ?? committedSettings.showPointsInMainUi,
5370
+ refreshIntervalMinutes: optimisticSettings.current.refreshIntervalMinutes ?? committedSettings.refreshIntervalMinutes
5371
+ };
5264
5372
  const settingsIds = (0, react.useId)();
5265
5373
  const creditsLabelId = `${settingsIds}-credits`;
5266
5374
  const creditsHintId = `${settingsIds}-credits-hint`;
5267
5375
  /**
5376
+ * The poll period, in milliseconds, as the CARD uses it.
5377
+ *
5378
+ * Derived from the committed-plus-optimistic value above rather than from the
5379
+ * document directly, so a pick shows its effect immediately: the timer is
5380
+ * rebuilt from this on the next render (see the poll effect's dependency
5381
+ * list). Reading it here also keeps the number and the picker in one place —
5382
+ * a picker on one cadence and a timer on another is precisely the class of
5383
+ * bug this setting replaced.
5384
+ */
5385
+ const refreshIntervalMs = settingsEdits.refreshIntervalMinutes * 6e4;
5386
+ /**
5268
5387
  * Write ONE preference switch, on the click that flipped it.
5269
5388
  *
5270
5389
  * This replaced a draft + 「保存」/「放弃」 pair, which is what the sibling
@@ -5302,6 +5421,47 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5302
5421
  }
5303
5422
  };
5304
5423
  /**
5424
+ * Change how often the plugin refreshes on its own
5425
+ * (`Config.refreshIntervalMinutes`).
5426
+ *
5427
+ * Same shape as {@link writePreference}, and for the same reason: this is a
5428
+ * standing preference, so it commits on the pick and is read-back verified —
5429
+ * a picker that merely LOOKS changed while the timer keeps its old period is
5430
+ * the failure mode worth guarding against here. The read-back compares
5431
+ * through `refreshIntervalMinutesOf`, so the value has to land as one of the
5432
+ * offered cadences rather than merely being present.
5433
+ *
5434
+ * The write alone changes nothing on screen until the settings snapshot
5435
+ * notifies, which the poll effect below is keyed on: the interval is rebuilt
5436
+ * from the new value on the next render, so the old timer is cleared and a
5437
+ * fresh one started. That is deliberate — a cadence change must take effect
5438
+ * without a reload, and it must not leave the previous interval running.
5439
+ */
5440
+ const writeRefreshInterval = async (value) => {
5441
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
5442
+ setSavingSettings(true);
5443
+ setWriteError(void 0);
5444
+ optimisticSettings.current = {
5445
+ ...optimisticSettings.current,
5446
+ refreshIntervalMinutes: value
5447
+ };
5448
+ setSettingsRevision((revision) => revision + 1);
5449
+ try {
5450
+ await writeSettingsField(settingsScope, "refreshIntervalMinutes", value, (readBack) => refreshIntervalMinutesOf(readBack) === value);
5451
+ } catch (error) {
5452
+ if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
5453
+ } finally {
5454
+ if (mounted.current) {
5455
+ optimisticSettings.current = {
5456
+ ...optimisticSettings.current,
5457
+ refreshIntervalMinutes: void 0
5458
+ };
5459
+ setSettingsRevision((revision) => revision + 1);
5460
+ }
5461
+ if (mounted.current) setSavingSettings(false);
5462
+ }
5463
+ };
5464
+ /**
5305
5465
  * Flip the OPTIONAL sidebar row on or off (`Config.sidebarPanel`).
5306
5466
  *
5307
5467
  * Two things happen on a verified write, and BOTH are required:
@@ -5563,7 +5723,7 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5563
5723
  const controller = new AbortController();
5564
5724
  const timer = window.setInterval(() => {
5565
5725
  refreshUsage(activeRegion, controller.signal);
5566
- }, POLL_INTERVAL_MS);
5726
+ }, refreshIntervalMs);
5567
5727
  return () => {
5568
5728
  window.clearInterval(timer);
5569
5729
  controller.abort();
@@ -5573,7 +5733,8 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
5573
5733
  activeRegion,
5574
5734
  activeRegionOn,
5575
5735
  refreshUsage,
5576
- status.status
5736
+ status.status,
5737
+ refreshIntervalMs
5577
5738
  ]);
5578
5739
  const rescanAccounts = async () => {
5579
5740
  setBusy(true);
@@ -6635,6 +6796,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
6635
6796
  onCreditPositionChange: (position) => {
6636
6797
  changeCreditPosition(activeRegion, position);
6637
6798
  },
6799
+ refreshIntervalMinutes: settingsEdits.refreshIntervalMinutes,
6800
+ onRefreshIntervalChange: (minutes) => {
6801
+ writeRefreshInterval(minutes);
6802
+ },
6638
6803
  sidebarPanelOn,
6639
6804
  sidebarPanelBusy,
6640
6805
  onToggleSidebarPanel: (value) => {
@@ -6715,6 +6880,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
6715
6880
  onCreditPositionChange: (position) => {
6716
6881
  changeCreditPosition(activeRegion, position);
6717
6882
  },
6883
+ refreshIntervalMinutes: settingsEdits.refreshIntervalMinutes,
6884
+ onRefreshIntervalChange: (minutes) => {
6885
+ writeRefreshInterval(minutes);
6886
+ },
6718
6887
  sidebarPanelOn,
6719
6888
  sidebarPanelBusy,
6720
6889
  onToggleSidebarPanel: (value) => {
@@ -7644,9 +7813,17 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7644
7813
  }
7645
7814
  }
7646
7815
  /**
7647
- * Automatic refresh period. Five minutes, matching the sidebar line: a credit
7648
- * balance moves on the scale of model calls, not seconds, and each read is two
7649
- * read-only upstream calls.
7816
+ * Automatic refresh period when nothing says otherwise.
7817
+ *
7818
+ * Five minutes, matching the sidebar line: a credit balance moves on the scale
7819
+ * of model calls, not seconds, and each read is two read-only upstream calls.
7820
+ *
7821
+ * The LIVE period is the user's `Config.refreshIntervalMinutes` (see
7822
+ * {@link refreshIntervalMsOf}), whose default is this same five minutes — so
7823
+ * this constant is now the fallback for a caller that renders the readout
7824
+ * without a settings scope (a test, or a host that serves none), not the rule.
7825
+ * Both polling surfaces read that ONE field, so the composer row and the card
7826
+ * cannot end up refreshing the same routes at different rates.
7650
7827
  */
7651
7828
  const COMPOSER_POINTS_REFRESH_INTERVAL_MS = 3e5;
7652
7829
  /**
@@ -7745,6 +7922,16 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7745
7922
  if (t === void 0) throw new Error("Composer points readout requires its translation function");
7746
7923
  const owned = provider === void 0 ? void 0 : TRAE_COMPOSER_PROVIDERS[provider];
7747
7924
  const activeRegion = region ?? owned;
7925
+ /**
7926
+ * The poll period, from the user's own cadence setting.
7927
+ *
7928
+ * Read through the SHARED reader (the card's poll reads the same field the
7929
+ * same way), with the constant above as the fallback when this readout is
7930
+ * rendered with no settings scope at all. It re-reads on every render, so the
7931
+ * scope's notification after a write is enough to rebuild the timer — the
7932
+ * effect below lists it as a dependency for exactly that reason.
7933
+ */
7934
+ const refreshIntervalMs = settingsScope === void 0 ? COMPOSER_POINTS_REFRESH_INTERVAL_MS : refreshIntervalMsOf(settingsScope.getSnapshot().value);
7748
7935
  const [usage, setUsage] = (0, react.useState)(void 0);
7749
7936
  const [accountCredits, setAccountCredits] = (0, react.useState)(void 0);
7750
7937
  /**
@@ -7816,6 +8003,23 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7816
8003
  mounted.current = false;
7817
8004
  };
7818
8005
  }, []);
8006
+ /**
8007
+ * Re-render when the settings document changes, so a cadence picked in the
8008
+ * card reaches THIS readout without a reload.
8009
+ *
8010
+ * The scope is a stable injected reference, so the subscription is set up once
8011
+ * per scope. `settingsRevision` is not read anywhere — bumping it is the whole
8012
+ * point, since `refreshIntervalMs` above is recomputed on every render.
8013
+ *
8014
+ * The gate above (`ComposerPointsGate`) already subscribes for its own two
8015
+ * switches, but a child does not re-render because its PARENT subscribed: this
8016
+ * component is mounted through that gate, so without its own subscription a
8017
+ * cadence change would only land on the next unrelated render.
8018
+ */
8019
+ const [, setSettingsRevision] = (0, react.useState)(0);
8020
+ (0, react.useEffect)(() => settingsScope?.subscribe(() => {
8021
+ setSettingsRevision((revision) => revision + 1);
8022
+ }), [settingsScope]);
7819
8023
  const position = useAnchoredPosition({
7820
8024
  open: open && activeRegion !== void 0,
7821
8025
  anchorRef: rootRef,
@@ -7877,14 +8081,18 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
7877
8081
  if (cancelled) return;
7878
8082
  timer = window.setInterval(() => {
7879
8083
  fetchUsage(controller.signal);
7880
- }, COMPOSER_POINTS_REFRESH_INTERVAL_MS);
8084
+ }, refreshIntervalMs);
7881
8085
  });
7882
8086
  return () => {
7883
8087
  cancelled = true;
7884
8088
  if (timer !== void 0) window.clearInterval(timer);
7885
8089
  controller.abort();
7886
8090
  };
7887
- }, [owned, fetchUsage]);
8091
+ }, [
8092
+ owned,
8093
+ fetchUsage,
8094
+ refreshIntervalMs
8095
+ ]);
7888
8096
  /**
7889
8097
  * Every account's PLAN, one status read per account — ai only.
7890
8098
  *
@@ -8844,6 +9052,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
8844
9052
  "row.creditPositionStats": "below the input box",
8845
9053
  "row.creditPositionAria": "Credit readout position for {region}",
8846
9054
  "row.creditPositionHint": "The default position is on the model picker row. If another connector occupies the same position, the readout loaded later moves left automatically. Choose below the input box to show it beside the round, step and token statistics.",
9055
+ "row.refreshIntervalLabel": "refresh every",
9056
+ "row.refreshIntervalOption": "{minutes} min",
9057
+ "row.refreshIntervalAria": "Automatic refresh interval",
9058
+ "row.refreshIntervalHint": "How often the card and the composer readout re-read usage by themselves, every 5 minutes by default. Each refresh is two read-only upstream requests, so a shorter interval means more traffic; nothing here spends credits. A change takes effect immediately, and turning a panel off stops its reads.",
8847
9059
  "composer.switchTo": "Switch to {account}",
8848
9060
  "composer.noAccounts": "No account in this region",
8849
9061
  "composer.panelTitle": "Trae credits",
@@ -9263,6 +9475,10 @@ ${u} ${p}-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
9263
9475
  "row.creditPositionStats": "输入框下方",
9264
9476
  "row.creditPositionAria": "{region} 的积分显示位置",
9265
9477
  "row.creditPositionHint": "默认与模型选择同一行显示;遇到其他连接器占用排序位置时,后加载的读数自动向左排列。也可选择「输入框下方」,与轮次、步数和 token 统计同一行显示。",
9478
+ "row.refreshIntervalLabel": "自动刷新",
9479
+ "row.refreshIntervalOption": "{minutes} 分钟",
9480
+ "row.refreshIntervalAria": "自动刷新间隔",
9481
+ "row.refreshIntervalHint": "卡片与输入框读数各自多久重新读取一次用量,默认 5 分钟。每次刷新是两次只读的上游请求,间隔越短请求越频繁;这里不消耗积分。改动即时生效;面板关闭后该面板不再自动读取。",
9266
9482
  "composer.switchTo": "切换到 {account}",
9267
9483
  "composer.noAccounts": "该区域暂无账号",
9268
9484
  "composer.panelTitle": "Trae 积分",
package/lib/index.d.ts CHANGED
@@ -462,14 +462,22 @@ interface TraeWireModel {
462
462
  * account's custom models (`usage: 'custom_model'`); none of those belong
463
463
  * in a model picker. Measured: this one test removed 23 of 50 wire-only
464
464
  * rows, and every row it removed was a tool or a custom model.
465
- * - `config_source === 1` — a Trae preset, not the account's own custom
466
- * model (source 3, which carries the user's own base_url and key). The
467
- * plugin must not offer to bill a model it does not host.
465
+ * - `config_source === 1`, or `2` once the caller opts in — `1` is a Trae
466
+ * preset; `2` is a model the tenant's own enterprise console configured
467
+ * (see `allowTenantConfigured` on {@link mergeTraeModelSources}, which is
468
+ * what decides whether a `2` may pass). `3` is the account's own custom
469
+ * model, carrying the user's own base_url and key: the plugin must not
470
+ * offer to bill a model it does not host, so `3` never passes.
468
471
  * - a REAL display name — not empty, not the upstream placeholder `-`, not a
469
472
  * bare config identifier (`search_agent_qwen_fast`,
470
473
  * `computer_use_subagent`), and not the config's OWN id. A model with no
471
474
  * name has nothing to show in the picker.
472
475
  *
476
+ * A tenant-configured row (`2`) is EXEMPT from the "own id" clause: those
477
+ * rows commonly publish the config_name as the display name, so the clause
478
+ * would reject precisely the rows the opt-in exists to surface. The other
479
+ * name tests still apply to them.
480
+ *
473
481
  * The display-name-collision test lives in the merge itself (it needs the
474
482
  * Remote rows to compare against); see {@link mergeTraeModelSources}. It is a
475
483
  * CAPABILITY test there, not a name test: a collision only suppresses a row
@@ -535,8 +543,19 @@ declare function isPromotableWireModel(model: TraeWireModel): boolean;
535
543
  * PROMOTED (issue #46) — subject to {@link isPromotableWireModel}. The
536
544
  * skeleton systematically omits the dated snapshot configs, so without
537
545
  * promotion every snapshot the IDE offers is unreachable from the plugin.
546
+ *
547
+ * @param options.allowTenantConfigured - may a `config_source: 2` row (a model
548
+ * the tenant's enterprise console configured) be promoted? Opt-in, and only
549
+ * the enterprise panel opts in: those rows bill the tenant pool, so surfacing
550
+ * them on a consumer panel would offer a model outside the caller's billing
551
+ * face. The rows also are not callable through `llm_utils_chat` today — they
552
+ * are client-direct with their own base_url (`use_remote_service: false`) and
553
+ * the gateway answers `4001` — which the discovery layer does not hide, but
554
+ * which is why this is a narrow opt-in rather than a default.
538
555
  */
539
- declare function mergeTraeModelSources(remote: readonly TraeDiscoveredModel[], wire: readonly TraeWireModel[]): TraeModelInfo[];
556
+ declare function mergeTraeModelSources(remote: readonly TraeDiscoveredModel[], wire: readonly TraeWireModel[], options?: {
557
+ allowTenantConfigured?: boolean;
558
+ }): TraeModelInfo[];
540
559
  /**
541
560
  * Demote a NON-chat `wireFunction` to "no function", keeping the row.
542
561
  *
@@ -1331,6 +1350,18 @@ declare function regionCreditPositionOf(value: unknown, panel: TraePanel): TraeC
1331
1350
  * 'regions', ...)` needs.
1332
1351
  */
1333
1352
  declare function nextRegionCreditPosition(value: unknown, panel: TraePanel, position: TraeCreditPosition): Record<string, unknown>;
1353
+ /**
1354
+ * The automatic-refresh cadences this plugin offers, in MINUTES.
1355
+ *
1356
+ * The list is the setting's whole value domain: the schema validates against
1357
+ * exactly these, so a hand-edited `7` is refused at write time instead of
1358
+ * silently producing a cadence nobody chose. `1` is the floor because every
1359
+ * usage read costs two upstream requests and a tighter loop would turn a
1360
+ * background refresh into traffic the user never asked for.
1361
+ */
1362
+ declare const TRAE_REFRESH_INTERVAL_MINUTES: readonly [1, 5, 10, 30, 60];
1363
+ /** One accepted cadence. */
1364
+ type TraeRefreshIntervalMinutes = typeof TRAE_REFRESH_INTERVAL_MINUTES[number];
1334
1365
  /** Subscription status of an international (ai) account, rendered instead of the CN credit packs. */
1335
1366
  interface TraeWebPayStatus {
1336
1367
  isDollarUsageBilling: boolean;
@@ -4821,6 +4852,23 @@ interface Config {
4821
4852
  * where the entry seeds the switch at page load).
4822
4853
  */
4823
4854
  sidebarPanel?: boolean;
4855
+ /**
4856
+ * How often the plugin refreshes its own readouts, in MINUTES.
4857
+ *
4858
+ * ONE plugin-wide number rather than one per region, because the thing it
4859
+ * paces is the plugin's background chatter, not a region's data: both
4860
+ * surfaces that poll (the card's usage document and the composer readout)
4861
+ * read the same routes at the same cost, and a per-region copy would let the
4862
+ * same screen refresh at two cadences.
4863
+ *
4864
+ * `undefined` means the default (5 minutes), which is what both surfaces
4865
+ * already used before this was settable — so an upgrade moves nothing.
4866
+ * The accepted values are exactly {@link TRAE_REFRESH_INTERVAL_MINUTES}
4867
+ * (1 / 5 / 10 / 30 / 60); the schema below refuses anything else, and the
4868
+ * readers fall back to the default for a document that never went through
4869
+ * it (a hand-edited file, or a profile written before this field existed).
4870
+ */
4871
+ refreshIntervalMinutes?: TraeRefreshIntervalMinutes;
4824
4872
  /**
4825
4873
  * Per-region model state, keyed `cn` | `ai`. The CN and international apps
4826
4874
  * expose different rosters, so each keeps its own directory and selection
package/lib/index.js CHANGED
@@ -371,14 +371,22 @@ function applyImageSelection(models, selected) {
371
371
  * account's custom models (`usage: 'custom_model'`); none of those belong
372
372
  * in a model picker. Measured: this one test removed 23 of 50 wire-only
373
373
  * rows, and every row it removed was a tool or a custom model.
374
- * - `config_source === 1` — a Trae preset, not the account's own custom
375
- * model (source 3, which carries the user's own base_url and key). The
376
- * plugin must not offer to bill a model it does not host.
374
+ * - `config_source === 1`, or `2` once the caller opts in — `1` is a Trae
375
+ * preset; `2` is a model the tenant's own enterprise console configured
376
+ * (see `allowTenantConfigured` on {@link mergeTraeModelSources}, which is
377
+ * what decides whether a `2` may pass). `3` is the account's own custom
378
+ * model, carrying the user's own base_url and key: the plugin must not
379
+ * offer to bill a model it does not host, so `3` never passes.
377
380
  * - a REAL display name — not empty, not the upstream placeholder `-`, not a
378
381
  * bare config identifier (`search_agent_qwen_fast`,
379
382
  * `computer_use_subagent`), and not the config's OWN id. A model with no
380
383
  * name has nothing to show in the picker.
381
384
  *
385
+ * A tenant-configured row (`2`) is EXEMPT from the "own id" clause: those
386
+ * rows commonly publish the config_name as the display name, so the clause
387
+ * would reject precisely the rows the opt-in exists to surface. The other
388
+ * name tests still apply to them.
389
+ *
382
390
  * The display-name-collision test lives in the merge itself (it needs the
383
391
  * Remote rows to compare against); see {@link mergeTraeModelSources}. It is a
384
392
  * CAPABILITY test there, not a name test: a collision only suppresses a row
@@ -391,9 +399,10 @@ function applyImageSelection(models, selected) {
391
399
  */
392
400
  function isPromotableWireModel(model) {
393
401
  if (model.usage !== "chat_completion") return false;
394
- if (model.configSource !== 1) return false;
402
+ if (model.configSource !== 1 && model.configSource !== 2) return false;
395
403
  const name = model.name.trim();
396
404
  if (name === "" || name === "-") return false;
405
+ if (model.configSource === 2) return true;
397
406
  if (name === model.id.trim()) return false;
398
407
  if (!name.includes(" ") && name.includes("_")) return false;
399
408
  return true;
@@ -503,8 +512,17 @@ function wireEffortsOf(supported) {
503
512
  * PROMOTED (issue #46) — subject to {@link isPromotableWireModel}. The
504
513
  * skeleton systematically omits the dated snapshot configs, so without
505
514
  * promotion every snapshot the IDE offers is unreachable from the plugin.
515
+ *
516
+ * @param options.allowTenantConfigured - may a `config_source: 2` row (a model
517
+ * the tenant's enterprise console configured) be promoted? Opt-in, and only
518
+ * the enterprise panel opts in: those rows bill the tenant pool, so surfacing
519
+ * them on a consumer panel would offer a model outside the caller's billing
520
+ * face. The rows also are not callable through `llm_utils_chat` today — they
521
+ * are client-direct with their own base_url (`use_remote_service: false`) and
522
+ * the gateway answers `4001` — which the discovery layer does not hide, but
523
+ * which is why this is a narrow opt-in rather than a default.
506
524
  */
507
- function mergeTraeModelSources(remote, wire) {
525
+ function mergeTraeModelSources(remote, wire, options = {}) {
508
526
  const wireByName = /* @__PURE__ */ new Map();
509
527
  const wireById = /* @__PURE__ */ new Map();
510
528
  for (const model of wire) if (!wireById.has(displayKey(model.id))) wireById.set(displayKey(model.id), model);
@@ -574,6 +592,7 @@ function mergeTraeModelSources(remote, wire) {
574
592
  for (const model of remote) claim(model.name, model.contextWindow);
575
593
  for (const wireModel of wire) {
576
594
  if (remoteIds.has(displayKey(wireModel.id))) continue;
595
+ if (wireModel.configSource === 2 && options.allowTenantConfigured !== true) continue;
577
596
  if (!isPromotableWireModel(wireModel)) continue;
578
597
  const collides = claimedNames.has(displayKey(wireModel.name));
579
598
  if (collides && (wireModel.contextWindow ?? 0) <= (claimedNames.get(displayKey(wireModel.name)) ?? 0)) continue;
@@ -1504,6 +1523,10 @@ const INERT_AUTH = {
1504
1523
  *
1505
1524
  * `imageOffloadCountQuantum` must be a positive integer no greater than
1506
1525
  * `maxRequestImages`; 10 ≤ 50 keeps the host's default ratio.
1526
+ *
1527
+ * The BYTE bound is the one field here that is not a host contract: it has to
1528
+ * match the gateway at the other end, and the gateways disagree (issue #81).
1529
+ * See {@link ENTERPRISE_MAX_REQUEST_IMAGE_BYTES}.
1507
1530
  */
1508
1531
  const REQUEST_IMAGE_BUDGETS = {
1509
1532
  maxRequestImageBytes: 20971520,
@@ -1512,6 +1535,54 @@ const REQUEST_IMAGE_BUDGETS = {
1512
1535
  maxRequestImages: 50,
1513
1536
  imageOffloadCountQuantum: 10
1514
1537
  };
1538
+ /**
1539
+ * Request-image byte bound for the enterprise route, in bytes (8 MiB).
1540
+ *
1541
+ * WHY THE HOST DEFAULT IS WRONG HERE. `maxRequestImageBytes` is not a taste
1542
+ * setting — DSH compares it against the base64 aggregate of every image a
1543
+ * request re-inlines, and raising `IMAGE_OFFLOAD_REQUIRED` is how the oldest
1544
+ * images get replaced by placeholders and the request retried. That only works
1545
+ * if the bound is BELOW what the gateway accepts: a bound above it means the
1546
+ * host believes an unsendable request fits and sends it anyway.
1547
+ *
1548
+ * Measured 2026-11-09 with unauthenticated probes (openresty rejects on body
1549
+ * size before authentication, so no credentials or credits are involved):
1550
+ *
1551
+ * console.enterprise.trae.cn, /api/agent/v3/create_agent_task AND
1552
+ * /api/agent/v3/llm_utils_chat:
1553
+ * 10_485_758 bytes -> 401 10_485_762 bytes -> 413
1554
+ * => the cap is exactly 10 MiB (10_485_760)
1555
+ *
1556
+ * The other two regions have no comparable cap: `trae-api-cn.mchost.guru`
1557
+ * answered 401 at 48 MiB and `coresg-normal.trae.ai` at 20 MiB, so they keep
1558
+ * the host default — lowering them would offload images those gateways accept.
1559
+ *
1560
+ * WHY 8 MiB AND NOT 10. The budget bounds the IMAGES; the request also carries
1561
+ * the system prompt, tool declarations and message text. 8 MiB leaves 2 MiB of
1562
+ * envelope, which the measured requests did not come close to (tool
1563
+ * declarations ~49 KB). The failure this fixes: a real 15-image session reached
1564
+ * 12.32 MB of base64 image payload, under the old 20 MiB budget and over the
1565
+ * 10 MiB cap, and died at step 20 with `413 Request Entity Too Large`.
1566
+ */
1567
+ const ENTERPRISE_MAX_REQUEST_IMAGE_BYTES = 8388608;
1568
+ /**
1569
+ * Image-request budgets for one route.
1570
+ *
1571
+ * The byte bound is region-specific; every other field is shared. Kept as a
1572
+ * function rather than two constants so the shared fields cannot drift apart.
1573
+ *
1574
+ * The return type is the profile's own field types rather than
1575
+ * `typeof REQUEST_IMAGE_BUDGETS`: that constant is `as const`, so its
1576
+ * `maxRequestImageBytes` is the LITERAL `20_971_520` and the enterprise arm
1577
+ * would not typecheck against it.
1578
+ * @param providerId - the route whose gateway will receive the request.
1579
+ */
1580
+ function requestImageBudgets(providerId) {
1581
+ return providerId === "trae-enterprise" ? {
1582
+ ...REQUEST_IMAGE_BUDGETS,
1583
+ maxRequestImageBytes: ENTERPRISE_MAX_REQUEST_IMAGE_BYTES
1584
+ } : REQUEST_IMAGE_BUDGETS;
1585
+ }
1515
1586
  function toPiModel(info, baseUrl, providerId, withRate = true) {
1516
1587
  return {
1517
1588
  id: info.id,
@@ -1885,7 +1956,7 @@ function createTraeAdapter(options) {
1885
1956
  configuredMaxTokens: /* @__PURE__ */ new Map(),
1886
1957
  modelErrors: /* @__PURE__ */ new Map(),
1887
1958
  defaultContextWindow: FALLBACK_CONTEXT_WINDOW,
1888
- ...REQUEST_IMAGE_BUDGETS,
1959
+ ...requestImageBudgets(providerId),
1889
1960
  piProvider: provider
1890
1961
  };
1891
1962
  let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
@@ -2411,6 +2482,23 @@ function nextRegionCreditPosition(value, panel, position) {
2411
2482
  });
2412
2483
  }
2413
2484
  /**
2485
+ * The automatic-refresh cadences this plugin offers, in MINUTES.
2486
+ *
2487
+ * The list is the setting's whole value domain: the schema validates against
2488
+ * exactly these, so a hand-edited `7` is refused at write time instead of
2489
+ * silently producing a cadence nobody chose. `1` is the floor because every
2490
+ * usage read costs two upstream requests and a tighter loop would turn a
2491
+ * background refresh into traffic the user never asked for.
2492
+ */
2493
+ const TRAE_REFRESH_INTERVAL_MINUTES = [
2494
+ 1,
2495
+ 5,
2496
+ 10,
2497
+ 30,
2498
+ 60
2499
+ ];
2500
+ new Set(TRAE_REFRESH_INTERVAL_MINUTES);
2501
+ /**
2414
2502
  * Panels whose text-to-image path this plugin has actually MEASURED, in
2415
2503
  * `TRAE_PANELS` tab order.
2416
2504
  *
@@ -11652,6 +11740,7 @@ const Config = z.object({
11652
11740
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
11653
11741
  showPointsInMainUi: asVolatile(z.boolean().default(false).description("在输入框工具栏显示积分读数「Trae CN · <数值>」(仅当选中的模型属于本插件时出现,可点击查看账号与积分明细)")),
11654
11742
  sidebarPanel: asVolatile(z.boolean().default(false).description("在 DSH 侧边栏显示「Trae」面板:点开是这张卡片的整页版。默认关闭——这一行占的是所有插件共用的全局侧边栏座位,而「插件」页本来就能看到同样的内容。点一下即生效,不用保存。")),
11743
+ refreshIntervalMinutes: asVolatile(z.union(TRAE_REFRESH_INTERVAL_MINUTES).default(5).description("自动刷新间隔(分钟):卡片与输入框读数共用。可选 1 / 5 / 10 / 30 / 60,默认 5。每次刷新会读取用量(两次只读的上游请求),间隔越短请求越频繁。")),
11655
11744
  accounts: asVolatile(accountSelectionConfig.description("Per-panel account selections, keyed cn | enterprise | ai")),
11656
11745
  regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-panel model directory and selection, keyed cn | enterprise | ai")),
11657
11746
  lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
@@ -12109,7 +12198,7 @@ function apply(ctx, config) {
12109
12198
  discoverModels: async (signal) => {
12110
12199
  const remote = await withDirectoryRetry(() => remoteCatalog.fetchModels(signal));
12111
12200
  const [wireModels, roster] = await Promise.all([solo.fetchModels(signal), officialRoster.fetch(signal)]);
12112
- const merged = mergeTraeModelSources(remote, wireModels);
12201
+ const merged = mergeTraeModelSources(remote, wireModels, { allowTenantConfigured: panel === "enterprise" });
12113
12202
  const callable = withChatCallableFunctions(merged, TRAE_DIRECTORY_FUNCTIONS[region]);
12114
12203
  const chatFunctions = TRAE_DIRECTORY_FUNCTIONS[region];
12115
12204
  for (const model of merged) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-trae",
3
3
  "displayName": "DSH Connect Trae",
4
4
  "description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
5
- "version": "4.16.0",
5
+ "version": "4.18.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",