dsh-connect-trae 2.3.1 → 2.5.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,103 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0 (2026-10-01)
4
+
5
+ > **国际版(AI 区域)三条用户可见故障的修复**,全部来自 [issue #19](https://github.com/dingminhua/dsh-connect-trae/issues/19) 的报告与
6
+ > 在真实 SG 凭据上的逐条实测(取证过程见 `docs/ISSUE19_DIAGNOSIS.md`,档位接口探索见 `docs/INTL_PAID_TIER_PROBE.md`)。
7
+
8
+ ### Features
9
+
10
+ - **国际版模型目录改为读取全部组并集——此前只读 `solo_agent_remote` 一个组,另外 9 个模型从未出现**(issue #19 症状一):
11
+ - Trae 的 remote `/models` 答复**按 function 分组**,且组间不是互替关系:实测 `solo_agent` 组 19 个模型、`solo_agent_remote` 组 10 个。旧代码请求 `solo_agent_remote,solo_work_remote` 后**只遍历 preferred 一个组**,报告人 Trae IDE 里能看到、插件里却没有的 `GPT-6-Astra`、`GPT-5.6-Sol/Terra/Luna`、`GPT-5.5`、`GLM-5.2` 等**恰好全部在没读的 `solo_agent` 组里**。
12
+ - 现在 `TRAE_REMOTE_DIRECTORY_FUNCTIONS` 按区域声明要请求的组(AI 区域三个全取),**每个返回的组都并入目录**,同一模型先出现者胜、只发一次。请求一个网关不认识的组是无害的(未知组名被忽略而非拒绝)。
13
+ - **发现面变宽,调用面反而收窄**:目录 union 让模型「可见」,但模型**只能通过列出它的那个 function 调用**。实测 `get_detail_param` 对 `solo_agent` 有求必应地返回 43 条,而 `llm_utils_chat` 对**所有**以 `function: "solo_agent"` 发出的模型一律回 `code 4011`——所以 wire 函数表 `TRAE_DIRECTORY_FUNCTIONS.ai` 从 `['solo_agent','solo_work_remote','solo_work_lite']` 改为 `['solo_work_remote','solo_work_lite','solo_agent_remote']`:让目录可见的新模型仍通过真正可调用的 function 去调,`solo_agent_remote` 放最后,使两个 function 都列出的模型保持无 Remote 门禁的 IDE function。CN 区域未重新测量,维持原列表不变(union 会保留网关返回的任何组)。
14
+
15
+ ### Bug Fixes
16
+
17
+ - **国际版订阅状态显示错误——Pro 会员被显示为「暂无有效套餐」**(issue #19 症状二):
18
+ - 卡片的判据只有 `has_package`,但那个字段回答的是「是否存在套餐权益对象」,不是「账号处于什么档位」——付费会员可以持有档位而没有套餐行,报告人的账号正是如此。
19
+ - Trae 同时返回的**权威档位字段** `user_pay_identity` / `user_pay_identity_str`(实测免费账号为 `0` / `"Free"`)此前在插件源码里**零引用**,被直接丢弃。
20
+ - 现在 `payStatus()` 完整保留两个档位字段(缺失时省略,不伪造),卡片判定改为 `has_package ∪ 档位非 Free`,并把档位名渲染进套餐行:「已有有效套餐 · Pro」/「暂无有效套餐 · Free」——免费用户看到**为什么**没有套餐,而不是只看到一个布尔。
21
+ - **只有精确的 `Free`(大小写不敏感)算免费**:未知或未来的档位名按付费处理。把付费会员错标成免费,比把免费账号显示成未知档位,是伤害更大的那个错误。
22
+ - **订阅门禁错误被吞成「Stream ended without finish_reason」**(issue #19 症状三):
23
+ - **现象**:免费/低档位账号选中需订阅的模型(如 `GPT-6-Sol`),得到的是一句像网络故障的报错,且会被重试多次。Trae 实际返回的是 **HTTP 200** + `event:error {"code":1005,"extra":"{\"plan\":N}"}`(`extra` 是 wire 上的 JSON **字符串**,订阅门禁只体现在 `extra.plan`)。
24
+ - **根因**:旧实现把上游答复原样以 200 转发,中途 `controller.error()` 截断流——HTTP 头已按 200 发出,`code 1005` 与 `plan` 在 HTTP 边界处丢失,pi-ai 只看到「流没给 finish_reason 就断了」,按传输故障处理。
25
+ - **修法**:`bridgeTraeSoloStreamOrFail` 在转发前**窥探流头**——任何输出开始之前的拒绝被翻译成真实的错误结果:`1005` → `402 hard_credit`(携带 plan 与「需要付费档」的文案,`hard_credit` 是让客户端停止而非重试的语义,402 是如实的状态码),`4011` / `4001` / 未知码 → `400 client`。code、plan、以及 Trae 那句与 code 相矛盾的原文(如 4011 自称"rate limit")**全部保留在 message 里**。
26
+ - 输出已开始后到来的拒绝(拒绝打断正在进行的回答):流以翻译后的可读错误终止,而不是裸截断。
27
+ - **窥探不伤健康流**:被窥探消费的头部字节逐字节重放;队列/进度/输出等任何 Trae 自有事件即触发直通,缓冲不超过首个事件。排队事件、分片工具调用等既有行为不变。
28
+ - **顺带登记两个此前不认识的错误码**(`grep` 证实源码与测试均零命中):`1005` = 订阅门禁(档位不够,`extra.plan` 指明所需档位);`4011` = 该 function 不提供此模型(其自报文案声称 rate limit,具有误导性)。已知的 `4001` = `config_name` 不被该 function 接受,语义不变。
29
+
30
+ ### Tests
31
+
32
+ - **全仓 373 → 392(+19),含 5 次变异验证全部被抓**:
33
+ - `tests/solo-bridge.spec.ts` **+11**:`describeTraeFailure` 文案 5 例(1005 带 plan、4011 用 hint 而把误导原文降级为 detail、未知码回退、无 code 事件不虚构后缀、已解包的 extra 对象也能读 plan);`bridgeTraeSoloStreamOrFail` 策略 5 例(1005→402 `hard_credit`、4011→400 `client`、无名事件经错误码带识别、健康流含排队事件直通且头部重放逐字节不丢、输出先行后拒绝的流中场景以可读 message 终止);`TraeSoloBridge` 端到端 1 例(完整链路上订阅门禁成为错误结果而非被截断的 200 流)。其中「流中拒绝」用分块流构造:单块 body 会被窥探一起解码,无法测到该路径。
34
+ - `tests/card-subscription.spec.tsx` **新文件 5 例**:渲染真实卡片断言档位行——Pro 从档位单独判定、无档位时套餐行仍工作、Free 命名而非只报布尔、试用窗口优先于档位文案,及 `traePlanIsPaid` 判据表(只有精确 Free 算免费,未知档位按付费)。一例说明:无档位字符串时 badge 与 value 渲染同一裸标签,`getByText` 会命中多个元素,须用 `getAllByText`——这例测试在上一会话中断前正以多元素匹配失败,本次接续修复。
35
+ - `tests/usage.spec.ts` **+2**:档位字段与 `has_package` 分开保留;上游不带档位时省略而非伪造。
36
+ - `tests/solo-remote.spec.ts` **+1**、`tests/solo.spec.ts` 改写既有断言:目录 union 行为、AI 区域 function 顺序(`solo_agent` 必须绝迹)。
37
+ - **变异验证**(确认测试有约束力,5 次全部被抓):① 分类忽略 1005 → 2 例失败;② plan 提取禁用 → 4 例失败;③ 窥探失效(打回旧行为,即本次要修的 bug)→ 4 例失败;④ 档位恒判免费 → 2 例失败;⑤ union 退回单组 → 3 例失败。恢复后 45 文件 392 例全绿。
38
+
39
+ ### Docs
40
+
41
+ - `docs/ISSUE19_DIAGNOSIS.md`(随 2.4.0 后的 `009d9a7` 提交):三个症状的完整取证——remote 目录两组的差集与报告人 IDE 的模型逐一吻合、`user_pay_identity_str` 零引用的 grep 证据、以真实上游字节喂给 bridge 复现「截断的 200」。
42
+ - `docs/INTL_PAID_TIER_PROBE.md`(`d76800d`):付费账号是否有专属接口的探索结论——**没有**,免费/付费同端点同目录,差别在调用时授权;含 `1005` / `4011` 的对照实验记录。
43
+
44
+ ## 2.4.0 (2026-09-29)
45
+
46
+ > **包含仍未发布的 2.3.2。** 2.3.2 的 peer 范围修复(`>=0.1.7-rc.1 <0.2.0-0` → `<0.3.0-0`,见下一节)
47
+ > 只提交到 git、未推送也未发 npm,因此**没有用户拿到过 2.3.2**;2.4.0 在同一棵树上,同时带上该修复。
48
+ > 若你希望「先单独发一个只含关键修复的补丁版」,就先推 2.3.2 的 tag 并 `npm publish`,再把本节改号为 2.4.0。
49
+
50
+ ### Features
51
+
52
+ - **识图能力改为跟随 Trae 自身的 `multimodal` 标注,不再要求逐一手勾**(issue #16):
53
+ - **原来的行为**:插件唯一决定是否给某个模型开图片输入的入口是 `applyImageSelection()`,判据只有用户手动勾选的 `imageModelIds`;上游 `multimodal` 字段虽然在 `parseTraeRemoteModel` 里被解析出来,但**从不进入 `TraeModelInfo`**,等于被丢弃。于是 Trae 自己标为多模态的模型,在这里也必须手动勾一次才认图。
54
+ - **改后的判据**:`你手动勾选的 ∪ Trae 标注 multimodal=true 的`(新增 `traeModelAcceptsImage()` 作为唯一判据函数,`applyImageSelection()` 走它)。
55
+ - **为什么是并集而不是"只信上游"**:上游标注是**建议**而非契约——`llm_utils_chat` 是轻量 SOLO 通道,本插件历史上多次被 `4001 param is invalid` 坑过。所以上游标注**只决定图片输入,从不决定模型可用性**:模型能否出现在目录里仍只由 wire(`get_detail_param` 的 `config_name`)join 决定,标注不会把一个不可调用的模型放进来。
56
+ - **为什么也不"只信手动"**:Trae IDE 自己的附图按钮就是纯粹由 `capabilities.vision` 驱动的,没有任何用户开关。厂商已声明的能力还要用户再勾一次,只有本插件这一处。
57
+ - **缺失或为 false 一律按纯文本**:`multimodal` 缺失(旧版本保存的目录、静态兜底目录)或明确 `false` 都不自动开。宁可不发,也不对能力不确定的模型谎报"支持"——一旦声明 `image`,DSH 会把图片原样发出而不是降级成文字。
58
+ - **自动开启 ≠ 自动发图**:它只是让 DSH 允许给该模型附图;图片始终由你在对话里自己贴,插件不会替你发送任何东西。
59
+ - **卡片同步**:被自动开启的行显示为**已勾选且只读**并带说明(新增 `row.modelImageAuto`)。并集语义下取消勾选会被立刻重新加上,给它一个可点的框是在撒谎。目前**不提供**单个模型"强制关闭"的出口(那需要额外的 opt-out 状态);如需请在该 issue 下提出。
60
+ - **实测结果**(本机 CN 账号,实时目录 15 个模型,用户零勾选):8 个自动获得图片输入(`Doubao-Seed-Evolving`、`Doubao-Seed-2.1-Pro`、`Doubao-Seed-2.1-Turbo`、`deepseek-v4.1-flash`、`kimi-k3`、`minimax-m3`、`qwen3.8-max`、`qwen-3.7-plus`);Trae 明确标 `false` 的 4 个(`glm-5.3`、`glm-5.2`、`DeepSeek-V4-Flash-Official`、`DeepSeek-V4-Pro-Official`)**正确地保持纯文本**;目录未标注的 3 个同样保持纯文本。
61
+ - **文档**:`docs/MODEL_MANAGEMENT_DESIGN.md` 里「仅展示,不作为图片授权依据」的描述随此改动失效(该字段现在确实参与授权,但**不**决定可用性);README 新增「图片输入(识图)」一节说明完整语义(中英同步)。
62
+
63
+ ### Bug Fixes
64
+
65
+ - **`multimodal` 必须随 `lastCatalog` 一起持久化,否则"自动识图"会在保存/重启后失效**:运行时目录每次保存与启动都从 `lastCatalog` 重新推导(`derive()` → `applyImageSelection()`)。若 schema 不声明该字段,它被剥掉后自动开图会立刻退回手动。已在 `modelConfig`(`src/index.ts`)显式声明 `multimodal: z.boolean()`,并在卡片保存处显式带上——卡片原本会强制把 `input` 写回 `['text']`(这是对的,`input` 由宿主推导),但 `multimodal` 是上游事实,不能跟着一起丢。
66
+ - **README「暂不支持」表格过期**:`deepseek-v4.1-flash` 已从该表移除。`docs/DS41_CALLABILITY.md`(2026-09-15)取证时 8 个 SOLO function 全部返回 `4001`,但**上游后来把它开放到了 SOLO 通道**——当前实时目录里它带 `wireFunction: solo_work_remote`,已实测刷新确认可调用。表内其余 3 个(`glm-5.3-flash`、`kimi-k2.8-preview`、`qwen3.8-flash`)实测仍不在目录里,保留在表中。
67
+
68
+ ### Tests
69
+
70
+ - **新增 6 例**(全仓 367 → 373),覆盖并集的四个方向:标注 true 自动开、标注 false 不开、缺失按不开、手动勾选永不撤销(含上游后来不再标注的情况),以及"别的模型的勾选不得串到本行"。
71
+ - **新增 2 例端到端**(走真实插件 + `ctx.llm.listModels()`):零勾选时标注为 true 的模型 `inputModalities === ['text','image']`;标注 false 的模型仍为 `['text']`。后者同时证明**手动勾选路径没被破坏**,且旧形状(不带 `multimodal` 的 `lastCatalog` 行)仍能通过 schema 校验。
72
+ - **变异验证**(确认测试有约束力,3 次全部被抓):① 把并集改回"仅手动勾选" → 3 例失败(含端到端那例);② 让 schema 不声明 `multimodal` → 无法编译(弱信号,已改写为更真实的形式);③ 在 `derive` 里于 `applyImageSelection` 之前剥掉该字段 → 端到端那例精确失败。恢复后 44 文件 373 例全绿。
73
+
74
+ ## 2.3.2 (2026-09-29)
75
+
76
+ ### Bug Fixes
77
+
78
+ - **插件在 DSH 0.2.0-rc.1 上整个不加载——peer 上界 `<0.2.0-0` 把 0.2.0 的预发布线一起挡掉了**(用户升级宿主后发现,2.3.1 的实际故障):
79
+ - **现象**:升级到 0.2.0-rc.1 后插件「凭空消失」——模型选择器里没有 `trae` / `trae-global`,插件设置卡片不见,用量路由 `/plugins/dsh-connect-trae/usage` 返回 **404**。不是某个功能坏掉,是 bundle 从未挂载。
80
+ - **根因**:DSH 自 0.1.7-rc.1 起有一道 **bundle 级 peer 门禁**(`@deepseek-ai/dsh-app-boot` 的 `evaluatePluginCompatibility`):`loadProfileDirectory()` 逐条比对 bundle 自己声明的 DSH peer,任一条不满足就**抛错并整体跳过该 bundle**,只往 stderr 写一行 `skipping profile bundle ...`。本插件 8 条范围写的是 `>=0.1.7-rc.1 <0.2.0-0`,而 **SemVer 里 `0.2.0-rc.1 < 0.2.0-0`**——`-0` 这个上界本意是「排除 0.2.0 正式版」,实际却把**整个 0.2.0 预发布线**也排除了。宿主的判定是 `semver.satisfies(runtime, range, {includePrerelease: true})`,在这条范围下对 `0.2.0-rc.1` 返回 `false`。
81
+ - **不是 0.2.0 破坏了契约**:逐符号核对确认本插件消费的 API 在 0.2.0-rc.1 上**全部未变**(`resolveRetryPolicy`、`PiAiAdapter`、`ResolvedPiAiProviderProfile`、`withFileLock`、`writeFileAtomic`、`resolveDshHome`、`AttachmentStore` 的声明逐字一致;插件触及的 `dsh-atomic-write` / `dsh-home-paths` / `dsh-attachment` / `dsh-settings` / `dsh-host-webserver` / `dsh-client-ui-slots` 源码**逐字节相同**)。同级 `dsh-connect-workbuddy` 2.1.1 写的是 `<0.2.0`——**放行 0.2.0 预发布、只挡正式版**,因此照常加载;本插件用的 `-0` 是同一意图下多写了一个 `-0`,恰好把预发布线也挡在门外。差别只在这一个字符。
82
+ - **也不是本插件独有的现象**:本机 profile 的 19 个 bundle 里有 **5 个**同时被这道门禁拒绝(本插件、`@changfenhuang/dsh-genui`、`dsh-better-reasoning-effort`、`dsh-free-search`、`dsh-rewind-plugin`)。用宿主自带的门禁逐条判定,这 5 个全部不在挂载行里、其余全部 `active`——判定与观测逐一吻合,排除了「判定模型与宿主不一致」。
83
+ - **修法**:8 条 peer 上界改为 `>=0.1.7-rc.1 <0.3.0-0`——放行 0.1.7 与 0.2 两条线(含各自的 alpha/rc),仍挡住未审查的 0.3 线。用宿主自己发行的门禁代码复验:0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-alpha.1 / 0.2.0-rc.1 / 0.2.0 / 0.2.9 全部放行,0.3.0-rc.1 / 0.3.0 / 1.0.0 仍被拒绝。
84
+ - **`devDependencies` 一并升到 0.2.0-rc.1**:否则本地 typecheck / 测试验的是 0.1.7 的契约,而用户跑的是 0.2.0——「开发树与用户解析不一致」正是上一轮 `pnpm-workspace.yaml` 里为 schemastery 写过的那类隐患。
85
+ - **为什么 CI 全绿却漏掉了**:范围写错不会让任何断言变红——`tsc` 通过(范围只是字符串)、345 条单测通过(插件自身代码没动)、`pnpm install` 通过(devDependency 钉的版本满足那段写错的范围)、CI 通过。**只有真实宿主会拒绝加载**。这个缺口已由下面的回归测试补上。
86
+
87
+ ### Tests
88
+
89
+ - **新增 `tests/dsh-peer-range.spec.ts`(+22 例,全仓 345 → 367)**:把「peer 范围必须覆盖哪些运行时」变成可执行的断言,而不只是一句文档。
90
+ - **逐条范围断言四个边界**:**必须放行** 0.1.7-rc.1 / 0.1.7-rc.2 / 0.1.9 / **0.2.0-alpha.1** / 0.2.0-rc.1 / 0.2.0 / 0.2.9;**必须拒绝** 0.3.0-rc.1 / 0.3.0 / 1.0.0。只断言「0.2.0-rc.1 能过」是不够的——那样把范围放宽成完全没有上界的 `>=0.1.7-rc.1` 也会通过。
91
+ - **镜像宿主而不是自创判定**:宿主用 `semver.satisfies(runtime, range, {includePrerelease: true})`,本测试用同一选项,并**单独断言该选项确实是必需的**(同一条范围在默认选项下会拒绝 `0.2.0-rc.1`)——否则将来 semver 语义一变,断言会静默地不再测宿主真正做的事。
92
+ - **点名禁止 `<0.2.0-0` 回归**:把这次故障的写法单独钉死,因为它在「整理版本范围」时很容易被误改回去。
93
+ - **守护开发树一致性**:断言每个声明了 peer 范围的包都在 `devDependencies` 里有钉住的版本、且该版本落在自己声明的范围内;另断言 `dsh.client.inject` 里的每个客户端包都有 devDependency 钉版本。(客户端 roster 那 5 个包没有 peer 范围——宿主从自己的图解析它们,因此只断言「有钉版本」,不虚构范围。)
94
+ - **变异验证**(确认测试真的有约束力,不是空转):把范围改回 `<0.2.0-0` → **10 例失败**;改成无上界的 `>=0.1.7-rc.1` → **8 例失败**;把 peer 下界抬到 `>=0.2.0-rc.1` 但留下 0.1.7 的 devDependency 钉 → **8 例失败**。三次变异全部被抓住,恢复后 22 例全绿。
95
+
96
+ ### Docs
97
+
98
+ - **新增 `docs/DSH_0.2.0_RC1_IMPACT_CHECK.md`**:0.1.7-rc.2 → 0.2.0-rc.1 的完整影响面核对(261 提交 / 1109 文件 / 0 处破坏性提交;插件触及的包逐字节比对;类型级与运行时值级符号核对各 0 处移除;端到端类型验证)。含方法论备注:宿主 `app.asar` 内的 payload 基准在本机实测为 `8 + headerSize`(不是常见的 `16 + headerSize`),需用条目 `integrity.hash` 做 SHA256 校验后定位,否则会读出错位数据。
99
+ - **README(中英)新增「peer 上界为什么不写 `<0.2.0`」**:给出三种写法的对照表与故障成因,说明该类错误为什么没有 CI 能拦、只能靠专门的测试。
100
+
3
101
  ## 2.3.1 (2026-09-26)
4
102
 
5
103
  ### Features
package/README.en.md CHANGED
@@ -39,6 +39,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
39
39
  - **DSH local tool loop** — gets pending structured `tool_calls` from Trae `llm_utils_chat`, lets DSH execute its own local tools, then returns tool results to the model.
40
40
  - **Dual-region detection (CN / international)** — detects local sign-ins across Trae CN, TRAE SOLO CN, Trae, and TRAE SOLO installs. The region is derived from the credential's own `userRegion` claim (host suffix and edition label as fallbacks), with no manual switch.
41
41
  - **Region-isolated directories and selections** — CN and international each keep their own model directory, enabled picks, image opt-ins, and context budgets, and each provider reads only its own slot.
42
+ - **Vision follows Trae's own declaration** — when Trae marks a model `multimodal`, the plugin advertises it as such with no manual tick per model (issue #16). The rule is "**models you tick ∪ models Trae marks multimodal**": models Trae marks `false`, or does not mark at all, stay text-only and can still be turned on by hand. A row enabled automatically is shown ticked and read-only, with the reason. See "Image input" below.
42
43
  - **Account switching** — refreshes the token list and lets users select an account without storing tokens in DSH settings.
43
44
  - **Read-only usage and model management** — Work/general credits on CN accounts, subscription/trial status on international ones; enable Trae models freely. Read-only queries consume nothing.
44
45
  - **Daily check-in claiming** — a one-click claim on the CN card (the button shows the daily reward, and reads "Claimed today" and disables itself once that account has been paid). This is the plugin's **only** operation that changes account state, and it runs only when you click. Trae allows **one check-in per device per day**: after switching accounts, if this machine's slot is already spent (by any account), the selected account cannot claim here that day — the card explains that rather than pretending the account was paid. The international side has no check-in activity, so no button is shown there.
@@ -63,24 +64,43 @@ Usage overview hits the read-only `https://api.trae.cn/trae/api/v2/pay/*` and `/
63
64
 
64
65
  > See `docs/IMPLEMENTATION_PLAN.md`, `docs/SOLO_ROUTE_DECISION.md`, `docs/USAGE_API_RESEARCH.md`.
65
66
 
67
+ ## Image input
68
+
69
+ The plugin declares whether a model accepts images, and DSH uses that to decide whether an attachment is **sent as-is** or **degraded into a text description**. That makes the declaration a hard switch, not a cosmetic label.
70
+
71
+ The rule is the **union of two sources** (issue #16):
72
+
73
+ | Source | Meaning |
74
+ |---|---|
75
+ | Trae's own `multimodal` field | Trae's own answer to "does this model read images". The Trae IDE's attach-image button is driven purely by this field, with no user switch |
76
+ | Your manual `imageModelIds` ticks | Explicit choice. Even if Trae later stops reporting the flag for that model, your tick is never revoked |
77
+
78
+ Notes:
79
+
80
+ - **Only `multimodal === true` enables it automatically.** A missing field (a directory saved by an older release, a fallback row) or an explicit `false` stays text-only — better to not send than to advertise a capability we cannot confirm.
81
+ - **Automatic does not mean automatic sending.** It only lets DSH attach an image to that model; you still choose to attach one per message. The plugin never sends an image on your behalf.
82
+ - **An automatically enabled row is shown ticked and read-only.** Under the union, unticking it would be re-applied immediately, so offering a clickable box would be a lie. There is currently no per-model "force off" escape hatch; if you want one, say so on issue #16.
83
+ - Manual ticks still work for models Trae marks `false` (or does not mark), which is the escape hatch for "upstream does not say so, but it actually works".
84
+
66
85
  ## Model coverage
67
86
 
68
87
  This plugin serves models from Trae's **SOLO channel** (`DeepSeek-V4-Flash-Official`, `DeepSeek-V4-Pro-Official`, `GLM-5.3`, `GLM-5.2`, `Kimi-K3`, `MiniMax-M3`, `Qwen3.8-Max`, `Doubao-Seed-*`, …), for both the domestic and international sides.
69
88
 
70
- The following four models currently come from the **TraeCode (Trae IDE) channel** and have **not** been opened up to the SOLO channel, so this plugin does **not** support them yet:
89
+ The following three models currently come from the **TraeCode (Trae IDE) channel** and have **not** been opened up to the SOLO channel, so this plugin does **not** support them yet:
71
90
 
72
91
  | Model |
73
92
  | --- |
74
- | `deepseek-v4.1-flash` |
75
93
  | `glm-5.3-flash` |
76
94
  | `kimi-k2.8-preview` |
77
95
  | `qwen3.8-flash` |
78
96
 
79
- These four are reachable only inside the Trae IDE client; there is no callable channel for them on the plugin side. **They can only be supported once Trae officially opens them up to the SOLO channel.**
97
+ These three are reachable only inside the Trae IDE client; there is no callable channel for them on the plugin side. **They can only be supported once Trae officially opens them up to the SOLO channel.**
98
+
99
+ > `deepseek-v4.1-flash` used to be listed here — `docs/DS41_CALLABILITY.md` (evidence gathered 2026-09-15) measured `4001 param is invalid` from all eight SOLO functions. **Trae has since opened it up to the SOLO channel**: it now ships in the live directory with `wireFunction: solo_work_remote` and calls succeed (verified against a live refresh). This page had not caught up, so it is removed from the table.
80
100
 
81
101
  > Note the distinction: **`GLM-5.3` is supported** (it goes through the SOLO channel), but **`glm-5.3-flash` is not** — they are different models.
82
102
 
83
- To use these four models today, use the **Trae IDE itself**.
103
+ To use these three models today, use the **Trae IDE itself**.
84
104
 
85
105
  ## Install
86
106
 
@@ -99,6 +119,23 @@ Restart the DSH process after install/update/uninstall.
99
119
  ## DSH version compatibility
100
120
 
101
121
  **Requires DSH 0.1.7-rc.1 and up** (since 2.3.0 the plugin no longer targets hosts older than 0.1.7; the peer dependency ranges are narrowed accordingly).
122
+ **The peer upper bound is `<0.3.0-0`, so both the 0.1.7 and the 0.2 line are supported.**
123
+
124
+ The 0.2.0 line breaks **no** contract this plugin consumes — a symbol-by-symbol check confirmed every API it imports is unchanged ([full 0.2.0-rc.1 impact check](https://github.com/dingminhua/dsh-connect-trae/blob/main/docs/DSH_0.2.0_RC1_IMPACT_CHECK.md)). But since 0.1.7 DSH enforces a **bundle-level peer gate**: a bundle whose peers are unsatisfied is skipped **whole**, with one line on stderr and nothing in the UI — the plugin simply disappears.
125
+
126
+ ### Why the upper bound is not `<0.2.0`
127
+
128
+ 2.3.1 wrote `<0.2.0-0`, meaning "not the 0.2.0 release yet". That spelling is **wrong**: in SemVer `0.2.0-rc.1 < 0.2.0-0`, so it excluded the entire 0.2.0 **prerelease** line, and the plugin went dead the moment a user installed the first 0.2.0 prerelease.
129
+
130
+ | Spelling | 0.1.7 line | 0.2.0 alpha/rc | 0.2.0 release | 0.3.0 |
131
+ | --- | --- | --- | --- | --- |
132
+ | `>=0.1.7-rc.1 <0.2.0-0` ❌ | ✅ | ❌ | ❌ | ❌ |
133
+ | `>=0.1.7-rc.1 <0.2.0` | ✅ | ✅ | ❌ | ❌ |
134
+ | `>=0.1.7-rc.1 <0.3.0-0` ✅ | ✅ | ✅ | ✅ | ❌ |
135
+
136
+ `tests/dsh-peer-range.spec.ts` pins this invariant: every peer range must admit the 0.1.7 and 0.2 lines, must reject the 0.3 line, and `<0.2.0-0` may not come back.
137
+
138
+ > **Why this needs a dedicated test**: a wrong range passes `tsc`, every unit test, `pnpm install`, and CI — only a real host refuses to load it. That is exactly how 2.3.1 shipped green.
102
139
 
103
140
  0.1.7 rebuilt the settings machinery, and the plugin follows the 0.1.7 contract:
104
141
 
package/README.md CHANGED
@@ -39,6 +39,7 @@
39
39
  - **DSH 本地工具循环** —— 通过 Trae `llm_utils_chat` 获取待执行的结构化 `tool_calls`,交由 DSH 自带的本地工具执行,再将工具结果回传模型。
40
40
  - **国内国际双区域自动识别** —— 自动发现 Trae CN / TRAE SOLO CN / Trae / TRAE SOLO 四个本地安装的登录账号;区域由凭证自带的 `userRegion` 声明自动判定(host 后缀与 edition 标签兜底),无需手动指定。
41
41
  - **目录与勾选按区域隔离** —— 国内版与国际版各一套模型目录、勾选、图片开关与上下文预算,两个供应商各自读各自的槽位。
42
+ - **识图能力跟随 Trae 自身的标注** —— Trae 把某个模型标为 `multimodal` 时,插件直接按多模态对外声明,无需你在卡片上逐一手勾(issue #16)。判据是「**你手动勾选的 ∪ Trae 标注多模态的**」:Trae 明确标 `false` 或未标注的模型保持纯文本,你可以照旧手动为它们打开图片输入。卡片上被自动开启的那一行显示为已勾选且只读,并说明原因。详见下方「图片输入(识图)」。
42
43
  - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。
43
44
  - **只读用量与模型管理** —— 国内版查看 Work 积分与通用积分,国际版查看订阅/试用状态;刷新/启用 Trae 模型;只读查询不消耗额度。
44
45
  - **每日签到领取** —— 国内版卡片上一键领取当日签到积分(按钮显示每日奖励,该账号已领取则显示「今日已领取」并禁用);这是插件里**唯一会改变账号状态**的操作,由你主动点击触发。Trae 的签到按「**每台设备每天一次**」限制:切换账号后如果本机今天的名额已经用掉(无论是哪个账号用的),当前账号在本机当天无法再签,卡片会说明原因而不是谎报「已领取」。国际版没有签到活动,因此不显示该按钮。
@@ -63,24 +64,45 @@ DSH PiAiAdapter(每个 provider 一套)
63
64
 
64
65
  > 详见 `docs/IMPLEMENTATION_PLAN.md`、`docs/SOLO_ROUTE_DECISION.md`、`docs/USAGE_API_RESEARCH.md`。
65
66
 
67
+ ## 图片输入(识图)
68
+
69
+ 插件对外声明「这个模型是否接受图片」,DSH 据此决定附图是**原样发出**还是**降级成文字描述**。所以这条声明是硬开关,不是一个无关紧要的标记。
70
+
71
+ 判据是**两个来源的并集**(issue #16):
72
+
73
+ | 来源 | 含义 |
74
+ |---|---|
75
+ | Trae 目录自带的 `multimodal` 字段 | Trae 自己对「这模型读不读图」的回答。Trae IDE 本体的附图按钮就是纯粹由这个字段驱动的,没有用户开关 |
76
+ | 你在卡片上手动勾选的 `imageModelIds` | 显式选择。即便 Trae 后来不再标注该模型,你的勾选也不会被撤掉 |
77
+
78
+ 要点:
79
+
80
+ - **只有 `multimodal === true` 才自动开启。** 字段缺失(旧版本保存的目录、兜底目录)或明确 `false`,都按纯文本处理——宁可不发,也不对一个能力不确定的模型谎报「支持图片」。
81
+ - **自动开启并不等于自动发图。** 它只是让 DSH 允许你给该模型附图;图片始终由你在对话里自己贴,插件不会替你发送任何东西。
82
+ - **被自动开启的行在卡片上显示为已勾选且只读。** 因为并集语义下取消勾选会被立刻重新加上,给它一个可点的框是在撒谎。目前不为单个模型提供「强制关闭」的出口;如果你需要这个开关,可以在 issue #16 下提出。
83
+ - 手动勾选对 Trae 标注 `false`(或未标注)的模型依然有效,这是为那些「上游没标但实际可用」的情况留的口子。
84
+
66
85
  ## 模型覆盖范围
67
86
 
68
87
  插件提供的是 Trae **SOLO 通道**的模型(`DeepSeek-V4-Flash-Official`、`DeepSeek-V4-Pro-Official`、`GLM-5.3`、`GLM-5.2`、`Kimi-K3`、`MiniMax-M3`、`Qwen3.8-Max`、`Doubao-Seed-*` 等),国内版与国际版均走这条通道。
69
88
 
70
- 以下 4 个模型目前来自 **TraeCode(Trae IDE)通道**,尚未开放到 SOLO 通道,因此本插件**暂不支持**:
89
+ 以下 3 个模型目前来自 **TraeCode(Trae IDE)通道**,尚未开放到 SOLO 通道,因此本插件**暂不支持**:
71
90
 
72
91
  | 模型 |
73
92
  | --- |
74
- | `deepseek-v4.1-flash` |
75
93
  | `glm-5.3-flash` |
76
94
  | `kimi-k2.8-preview` |
77
95
  | `qwen3.8-flash` |
78
96
 
79
- 这 4 个模型只在 Trae IDE 客户端内可用,插件侧没有可调用的通道。**需要等官方把它们开放到 SOLO 通道后,插件才能支持。**
97
+ 这 3 个模型只在 Trae IDE 客户端内可用,插件侧没有可调用的通道。**需要等官方把它们开放到 SOLO 通道后,插件才能支持。**
98
+
99
+ > `deepseek-v4.1-flash` 曾在此列——`docs/DS41_CALLABILITY.md`(2026-09-15 取证)实测 8 个 SOLO function
100
+ > 全部返回 `4001 param is invalid`。**上游后来把它开放到了 SOLO 通道**:当前实时目录里它带
101
+ > `wireFunction: solo_work_remote`,可正常调用(已实测刷新确认)。文档此前未同步,现已从表中移除。
80
102
 
81
103
  > 注意区分:**`GLM-5.3` 是支持的**(走 SOLO 通道),但 **`glm-5.3-flash` 不支持**——两者是不同的模型。
82
104
 
83
- 如果你现在就要用这 4 个模型,请直接在 **Trae IDE 本体**中使用。
105
+ 如果你现在就要用这 3 个模型,请直接在 **Trae IDE 本体**中使用。
84
106
 
85
107
  ## 安装
86
108
 
@@ -101,6 +123,28 @@ npm install dsh-connect-trae
101
123
  ## DSH 版本兼容
102
124
 
103
125
  **要求 DSH 0.1.7-rc.1 及以上**(2.3.0 起不再支持 0.1.7 之前的宿主;peer 依赖范围已按此收窄)。
126
+ **peer 上界为 `<0.3.0-0`:0.1.7 与 0.2 两条线均受支持。**
127
+
128
+ 0.2.0 线对本插件**没有契约破坏**——逐符号核对确认插件消费的 API 全部未变([完整的 0.2.0-rc.1 影响面核对](https://github.com/dingminhua/dsh-connect-trae/blob/main/docs/DSH_0.2.0_RC1_IMPACT_CHECK.md))。但 0.1.7 起 DSH 有一道**bundle 级 peer 门禁**:
129
+ peer 范围不满足的 bundle 会被**整体跳过**,只往 stderr 写一行,界面上表现为「插件凭空消失」。
130
+
131
+ ### peer 上界为什么不写 `<0.2.0`
132
+
133
+ 2.3.1 曾写 `<0.2.0-0`,意图是「先不支持 0.2.0 正式版」。这个写法**是错的**:
134
+ SemVer 里 `0.2.0-rc.1 < 0.2.0-0`,于是它把**整个 0.2.0 预发布线**也一并排除,
135
+ 用户装上第一个 0.2.0 预发布版后插件立即整体失效(2.3.1 的实际故障)。
136
+
137
+ | 写法 | 0.1.7 线 | 0.2.0 alpha/rc | 0.2.0 正式 | 0.3.0 |
138
+ | --- | --- | --- | --- | --- |
139
+ | `>=0.1.7-rc.1 <0.2.0-0` ❌ | ✅ | ❌ | ❌ | ❌ |
140
+ | `>=0.1.7-rc.1 <0.2.0` | ✅ | ✅ | ❌ | ❌ |
141
+ | `>=0.1.7-rc.1 <0.3.0-0` ✅ | ✅ | ✅ | ✅ | ❌ |
142
+
143
+ `tests/dsh-peer-range.spec.ts` 钉住这条不变式:逐条断言每个 peer 范围**必须**放行
144
+ 0.1.7 与 0.2 两条线、**必须**挡住 0.3 线,并禁止 `<0.2.0-0` 这种写法回归。
145
+
146
+ > **为什么需要专门的测试**:范围写错时 `tsc` 通过、全部单测通过、`pnpm install` 通过、
147
+ > CI 全绿——只有真实宿主会拒绝加载。这正是 2.3.1 一路绿着上线的原因。
104
148
 
105
149
  0.1.7 把设置体系整体重建,插件按 0.1.7 的契约工作:
106
150
 
package/lib/client.js CHANGED
@@ -140,6 +140,21 @@ window.__ModuleLoader__.load({
140
140
  enabled
141
141
  });
142
142
  }
143
+ /**
144
+ * Whether Trae's plan identity says this account is on a paying plan.
145
+ *
146
+ * `has_package` alone is NOT that question — it reports whether a package
147
+ * entitlement row exists, and a member can be on a plan without one (issue
148
+ * #19: a Pro account rendered as "No active package"). Trae spells the plan in
149
+ * `user_pay_identity_str`, so this reads that field, and only the exact word
150
+ * `Free` counts as free: an unknown or future tier name is treated as paid,
151
+ * because claiming "Free" for a tier we simply do not recognise is the more
152
+ * damaging of the two errors.
153
+ */
154
+ function traePlanIsPaid(payIdentityStr) {
155
+ const tier = payIdentityStr?.trim();
156
+ return tier !== void 0 && tier !== "" && !/^free$/i.test(tier);
157
+ }
143
158
  //#endregion
144
159
  //#region src/client/icon.ts
145
160
  /**
@@ -355,6 +370,24 @@ window.__ModuleLoader__.load({
355
370
  if (value >= 1e3 && value % 1e3 === 0) return `${value / 1e3}K`;
356
371
  return formatNumber(value);
357
372
  }
373
+ /**
374
+ * The subscription row's verdict.
375
+ *
376
+ * Two independent things can entitle an international account: a package row
377
+ * (`has_package`) and a plan identity (`user_pay_identity_str`). Reading only
378
+ * the first is what rendered a Pro member as "No active package" in issue #19,
379
+ * so either one counts. The tier string is carried separately so the row can
380
+ * name the plan instead of just asserting a boolean.
381
+ */
382
+ function payVerdict(payStatus) {
383
+ const tier = payStatus.payIdentityStr?.trim();
384
+ const paid = payStatus.hasPackage || traePlanIsPaid(payStatus.payIdentityStr);
385
+ return {
386
+ paid,
387
+ ...tier === void 0 || tier === "" ? {} : { tier },
388
+ label: paid ? "row.subscribed" : "row.noPackage"
389
+ };
390
+ }
358
391
  function dotStyle(status) {
359
392
  return { background: status === "signed-in" ? "var(--dsw-alias-state-success-primary, #22a06b)" : status === "error" ? "var(--dsw-alias-state-error-primary, #d92d20)" : "var(--dsw-alias-label-dimmed, #9aa0a6)" };
360
393
  }
@@ -663,6 +696,25 @@ window.__ModuleLoader__.load({
663
696
  const savedEnabledIds = status.status === "signed-in" ? new Set(status.enabledModelIds) : /* @__PURE__ */ new Set();
664
697
  const activeEnabledIds = draft?.enabledIds ?? savedEnabledIds;
665
698
  const activeImageIds = draft?.imageIds ?? savedImageIds;
699
+ /**
700
+ * Effective image capability of one row, mirroring what the Host reports to
701
+ * DSH: the user's opt-in unioned with Trae's own `multimodal` flag (issue
702
+ * #16). Rendering the union is what keeps this checkbox truthful — showing the
703
+ * raw `imageModelIds` would draw an unticked box next to a model the plugin
704
+ * is in fact advertising as image-capable.
705
+ */
706
+ const rowAcceptsImage = (model) => activeImageIds.has(model.id) || model.multimodal === true;
707
+ /**
708
+ * A row Trae itself declares multimodal needs no manual opt-in, so its box is
709
+ * shown ticked and read-only rather than offering a toggle that the union
710
+ * would immediately undo. Turning image input off for such a model is
711
+ * deliberately not supported: the flag is the vendor's own capability answer,
712
+ * and enabling it only lets DSH attach an image the user still has to pick per
713
+ * message — it never sends one by itself.
714
+ */
715
+ const rowImageIsAutomatic = (model) => model.multimodal === true;
716
+ /** The subscription row's verdict for the current account, when it has one. */
717
+ const payPlan = status.status === "signed-in" && status.payStatus !== void 0 ? payVerdict(status.payStatus) : void 0;
666
718
  const activeContextBudgets = draft?.contextBudgets ?? savedContextBudgets;
667
719
  const dirty = draft !== void 0;
668
720
  /** Apply an edit to the ACTIVE tab's draft, seeding it from the current view. */
@@ -725,7 +777,8 @@ window.__ModuleLoader__.load({
725
777
  enabled: regionOn(activeRegion),
726
778
  lastCatalog: visibleModels.map((model) => ({
727
779
  ...model,
728
- input: ["text"]
780
+ input: ["text"],
781
+ ...model.multimodal === void 0 ? {} : { multimodal: model.multimodal }
729
782
  })),
730
783
  enabledModelIds: [...activeEnabledIds],
731
784
  imageModelIds: [...activeImageIds].filter((id) => activeEnabledIds.has(id)),
@@ -887,7 +940,7 @@ window.__ModuleLoader__.load({
887
940
  })
888
941
  }) : null,
889
942
  status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
890
- status.payStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
943
+ status.payStatus === void 0 || payPlan === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
891
944
  className: "dsm-trae-usage-list",
892
945
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
893
946
  className: "dsm-trae-usage-stats dsm-trae-usage-stats-two",
@@ -900,13 +953,13 @@ window.__ModuleLoader__.load({
900
953
  className: "dsm-trae-usage-stat-label",
901
954
  children: t("row.subscriptionLabel")
902
955
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
903
- className: `dsm-trae-usage-stat-badge ${status.payStatus.hasPackage ? "dsm-trae-usage-stat-badge-on" : "dsm-trae-usage-stat-badge-off"}`,
904
- children: t(status.payStatus.hasPackage ? "row.subscribed" : "row.noPackage")
956
+ className: `dsm-trae-usage-stat-badge ${payPlan.paid ? "dsm-trae-usage-stat-badge-on" : "dsm-trae-usage-stat-badge-off"}`,
957
+ children: t(payPlan.label)
905
958
  })]
906
959
  }),
907
960
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
908
961
  className: "dsm-trae-usage-stat-value dsm-trae-usage-stat-value-general",
909
- children: status.payStatus.inTrial && status.payStatus.trialEndTimeMs > 0 ? t("row.trialUntil", { date: formatDateTime(status.payStatus.trialEndTimeMs) }) : t(status.payStatus.hasPackage ? "row.subscribed" : "row.noPackage")
962
+ children: status.payStatus.inTrial && status.payStatus.trialEndTimeMs > 0 ? t("row.trialUntil", { date: formatDateTime(status.payStatus.trialEndTimeMs) }) : [t(payPlan.label), payPlan.tier].filter(Boolean).join(" · ")
910
963
  }),
911
964
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
912
965
  className: "dsm-trae-usage-stat-hint",
@@ -1077,8 +1130,9 @@ window.__ModuleLoader__.load({
1077
1130
  className: "dsm-trae-model-image",
1078
1131
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1079
1132
  type: "checkbox",
1080
- checked: activeImageIds.has(model.id),
1081
- disabled: settingsScope?.getSnapshot().writable !== true || saving,
1133
+ checked: rowAcceptsImage(model),
1134
+ disabled: rowImageIsAutomatic(model) || settingsScope?.getSnapshot().writable !== true || saving,
1135
+ title: rowImageIsAutomatic(model) ? t("row.modelImageAuto") : void 0,
1082
1136
  onChange: () => {
1083
1137
  toggleImage(model.id);
1084
1138
  }
@@ -1270,11 +1324,12 @@ window.__ModuleLoader__.load({
1270
1324
  "row.contextBudget": "DSH context budget",
1271
1325
  "row.modelOutput": "Output {output}",
1272
1326
  "row.modelImage": "Image",
1327
+ "row.modelImageAuto": "Enabled automatically: Trae marks this model multimodal.",
1273
1328
  "row.modelRate": "{rate}x credits",
1274
1329
  "row.modelReasoning": "Reasoning: {efforts}",
1275
1330
  "row.modelReasoningSupported": "Reasoning: supported",
1276
1331
  "row.modelUnknown": "Unknown",
1277
- "row.modelCapabilityPending": "Image input is enabled only for models you select.",
1332
+ "row.modelCapabilityPending": "Image input follows Trae's own multimodal flag, plus anything you tick yourself.",
1278
1333
  "row.cheer": "Star on GitHub"
1279
1334
  };
1280
1335
  const zh = {
@@ -1349,11 +1404,12 @@ window.__ModuleLoader__.load({
1349
1404
  "row.contextBudget": "DSH 上下文预算",
1350
1405
  "row.modelOutput": "最大输出 {output}",
1351
1406
  "row.modelImage": "图片",
1407
+ "row.modelImageAuto": "已自动开启:Trae 把该模型标为多模态。",
1352
1408
  "row.modelRate": "积分 {rate}x",
1353
1409
  "row.modelReasoning": "推理强度:{efforts}",
1354
1410
  "row.modelReasoningSupported": "推理:支持",
1355
1411
  "row.modelUnknown": "未知",
1356
- "row.modelCapabilityPending": "仅对你手动勾选的模型启用图片输入。",
1412
+ "row.modelCapabilityPending": "图片输入跟随 Trae 自身的多模态标注,并叠加你手动勾选的模型。",
1357
1413
  "row.cheer": "鼓励一下"
1358
1414
  };
1359
1415
  //#endregion
package/lib/index.d.ts CHANGED
@@ -120,6 +120,23 @@ interface TraeModelInfo {
120
120
  wireConfigName?: string;
121
121
  /** The directory function this model must be called through (see {@link TraeWireModel.function}). */
122
122
  wireFunction?: string;
123
+ /**
124
+ * Trae's own `multimodal` flag for this model, carried through from the
125
+ * directory that advertised it. It is the upstream's answer to "does this
126
+ * model read images", and {@link applyImageSelection} unions it with the
127
+ * user's explicit opt-ins.
128
+ *
129
+ * Deliberately NOT the same thing as `input`: `input` is what DSH is told
130
+ * this route accepts, while this field records what Trae said. Keeping both
131
+ * lets a future refresh re-derive `input` without losing the upstream fact,
132
+ * and lets the card explain WHY a model is image-capable.
133
+ *
134
+ * Absent means the directory did not say (an older `lastCatalog` saved before
135
+ * this field existed, a fallback row, or a directory entry that omitted the
136
+ * key). Absent is treated as false, never as "assume capable": sending an
137
+ * image to a model that cannot read one burns credits for a silent failure.
138
+ */
139
+ multimodal?: boolean;
123
140
  }
124
141
  /**
125
142
  * Bootstrap catalog: identity only where current Trae metadata has not been
@@ -175,7 +192,7 @@ declare function traeInputModalities(model: Pick<TraeModelInfo, 'input'>): TraeI
175
192
  * model discovery) use this decorated name.
176
193
  */
177
194
  declare function traeModelDisplayName(model: Pick<TraeModelInfo, 'name' | 'creditMultiplier'>): string;
178
- /** Apply the user's explicit image opt-ins; upstream and saved row hints are ignored. */
195
+ /** Apply the user's explicit image opt-ins plus Trae's own multimodal flag. */
179
196
  declare function applyImageSelection(models: readonly TraeModelInfo[], selected: ReadonlySet<string>): TraeModelInfo[];
180
197
  /** One row from `get_detail_param`: the authoritative llm_utils_chat wire id + display name. */
181
198
  interface TraeWireModel {
@@ -239,7 +256,7 @@ type TraeContextBudget = number;
239
256
  * never to a fabricated number. Everything else keeps the dev window.
240
257
  */
241
258
  declare function applyContextBudgets(catalog: readonly TraeModelInfo[], budgets?: Readonly<Record<string, TraeContextBudget | undefined>>): TraeModelInfo[];
242
- /** Convert Trae metadata into text-only model rows; image support is user-owned configuration. */
259
+ /** Convert Trae metadata into catalog rows; image input is decided by {@link applyImageSelection}. */
243
260
  declare function discoveredCatalog(models: readonly TraeDiscoveredModel[]): TraeModelInfo[];
244
261
  /**
245
262
  * Drop rows saved by older releases that generated `@1m` variant models, so a
@@ -1309,6 +1326,19 @@ interface TraePayStatus {
1309
1326
  enableSoloBuilder: boolean;
1310
1327
  enableSoloCoder: boolean;
1311
1328
  enableSoloWeb: boolean;
1329
+ /**
1330
+ * The account's plan identity (`user_pay_identity`), Trae's authoritative
1331
+ * tier number: `0` is Free. Measured 2026-10-01 on a live SG community
1332
+ * account, which reported `0` / `"Free"`.
1333
+ *
1334
+ * This is a DIFFERENT question from {@link hasPackage}, which asks whether a
1335
+ * package/entitlement object exists. A paying member can hold a plan
1336
+ * identity without any package row, which is why issue #19 saw "No active
1337
+ * package" on a Pro account — the card only ever read `has_package`.
1338
+ */
1339
+ payIdentity?: number;
1340
+ /** The plan identity as Trae spells it (`user_pay_identity_str`), e.g. `Free`. */
1341
+ payIdentityStr?: string;
1312
1342
  /** The fission (referral) program window and cap, when exposed. */
1313
1343
  fission?: {
1314
1344
  startTimeMs: number;
@@ -1524,6 +1554,13 @@ interface TraeWebModel {
1524
1554
  defaultEffort?: string;
1525
1555
  };
1526
1556
  maxContextWindow?: number;
1557
+ /**
1558
+ * Trae's own image-capability flag, as the runtime catalog carries it. The
1559
+ * card renders these rows with their image checkbox already ticked so the
1560
+ * control matches what DSH is actually told (issue #16); absent means the
1561
+ * directory did not declare it, which is not a capability claim either way.
1562
+ */
1563
+ multimodal?: boolean;
1527
1564
  }
1528
1565
  /**
1529
1566
  * Daily check-in state rendered below the credit stats.
@@ -1671,6 +1708,10 @@ interface TraeWebPayStatus {
1671
1708
  enableSoloBuilder: boolean;
1672
1709
  enableSoloCoder: boolean;
1673
1710
  enableSoloWeb: boolean;
1711
+ /** Trae's authoritative tier number (`user_pay_identity`); `0` means Free. */
1712
+ payIdentity?: number;
1713
+ /** Trae's authoritative tier label (`user_pay_identity_str`), e.g. `Free`. */
1714
+ payIdentityStr?: string;
1674
1715
  fission?: {
1675
1716
  startTimeMs: number;
1676
1717
  expireTimeMs: number;
package/lib/index.js CHANGED
@@ -132,11 +132,45 @@ function traeInputModalities(model) {
132
132
  function traeModelDisplayName(model) {
133
133
  return model.creditMultiplier === void 0 ? model.name : `${model.name} · x${model.creditMultiplier.toFixed(2)}`;
134
134
  }
135
- /** Apply the user's explicit image opt-ins; upstream and saved row hints are ignored. */
135
+ /**
136
+ * Whether one model should be offered image input.
137
+ *
138
+ * Two independent sources, unioned (issue #16):
139
+ *
140
+ * - the user's explicit `imageModelIds` opt-in, which stays authoritative and
141
+ * can still switch an individual model on;
142
+ * - Trae's own `multimodal` flag, which is what makes "auto" work at all.
143
+ *
144
+ * Rationale: Trae's IDE enables its attach-image button purely from this flag
145
+ * (`metadata.capabilities.vision`), with no user switch, so a model Trae itself
146
+ * calls multimodal is one the vendor expects to receive images. Requiring a
147
+ * manual tick for those made this plugin the only place where a vendor-declared
148
+ * capability had to be re-enabled by hand.
149
+ *
150
+ * Why the flag alone is not enough, and the union is:
151
+ *
152
+ * - the flag is advisory, not a contract. `llm_utils_chat` is a lightweight
153
+ * SOLO channel and this plugin has been burned by `4001 param is invalid`
154
+ * for ids that LOOKED valid, so the flag is not treated as proof of
155
+ * callability — a model still has to have survived the wire join to appear
156
+ * at all. The flag only decides image input, never model availability.
157
+ * - Users who already ticked a model keep that tick even after a refresh stops
158
+ * reporting the flag, so the union never silently revokes a working setup.
159
+ * - `input` remains the ONLY thing told to DSH: a model that ends up without
160
+ * `image` here is one DSH will degrade images to text for, which is the
161
+ * safe direction for a model whose capability we cannot confirm.
162
+ *
163
+ * `multimodal === true` is a strict test: absent, `false`, or any non-boolean
164
+ * leaves the model text-only unless the user opted in.
165
+ */
166
+ function traeModelAcceptsImage(model, selected) {
167
+ return selected.has(model.id) || model.multimodal === true;
168
+ }
169
+ /** Apply the user's explicit image opt-ins plus Trae's own multimodal flag. */
136
170
  function applyImageSelection(models, selected) {
137
171
  return models.map((model) => ({
138
172
  ...model,
139
- input: selected.has(model.id) ? ["text", "image"] : ["text"]
173
+ input: traeModelAcceptsImage(model, selected) ? ["text", "image"] : ["text"]
140
174
  }));
141
175
  }
142
176
  /** Normalise a display name for cross-source joining. */
@@ -192,6 +226,7 @@ function mergeTraeModelSources(remote, wire) {
192
226
  ...model.maxContextWindow === void 0 ? {} : { maxContextWindow: model.maxContextWindow },
193
227
  ...creditMultiplier === void 0 ? {} : { creditMultiplier },
194
228
  input: ["text"],
229
+ ...model.multimodal === void 0 ? {} : { multimodal: model.multimodal },
195
230
  reasoningSupported: model.reasoningSupported,
196
231
  ...model.reasoning === void 0 ? {} : {
197
232
  reasoning: model.reasoning,
@@ -214,7 +249,7 @@ function applyContextBudgets(catalog, budgets = {}) {
214
249
  ...model.maxContextWindow !== void 0 && budgets[model.id] === model.maxContextWindow ? { contextWindow: model.maxContextWindow } : {}
215
250
  }));
216
251
  }
217
- /** Convert Trae metadata into text-only model rows; image support is user-owned configuration. */
252
+ /** Convert Trae metadata into catalog rows; image input is decided by {@link applyImageSelection}. */
218
253
  function discoveredCatalog(models) {
219
254
  const result = [];
220
255
  for (const model of models) result.push({
@@ -224,6 +259,7 @@ function discoveredCatalog(models) {
224
259
  ...model.maxContextWindow === void 0 ? {} : { maxContextWindow: model.maxContextWindow },
225
260
  input: ["text"],
226
261
  ...model.creditMultiplier === void 0 ? {} : { creditMultiplier: model.creditMultiplier },
262
+ ...model.multimodal === void 0 ? {} : { multimodal: model.multimodal },
227
263
  reasoningSupported: model.reasoningSupported,
228
264
  ...model.reasoning === void 0 ? {} : {
229
265
  reasoning: model.reasoning,
@@ -1906,7 +1942,7 @@ function applyReasoningEffort(body, effort, capability) {
1906
1942
  //#region src/solo.ts
1907
1943
  const TRAE_SOLO_FUNCTION = "solo_work_lite";
1908
1944
  /**
1909
- * Directory functions to union per region, in priority order.
1945
+ * `llm_utils_chat` functions to union per region, in priority order.
1910
1946
  *
1911
1947
  * Trae spreads its callable roster across several SOLO-mode functions, and a
1912
1948
  * model is only usable through the one that lists it: `glm-5.3` is absent from
@@ -1914,14 +1950,32 @@ const TRAE_SOLO_FUNCTION = "solo_work_lite";
1914
1950
  * calling it through the former answers `4001 param is invalid`, through the
1915
1951
  * latter streams normally). Rather than betting on a single function, the
1916
1952
  * directory unions them; the first function to provide a config wins, so the
1917
- * order below decides which wire name a model is called with.
1953
+ * order below decides which wire name AND which function a model is called with.
1954
+ *
1955
+ * THIS LIST IS NOT "directory functions" — it is the set of functions
1956
+ * `llm_utils_chat` actually accepts, because ownership here is what the chat
1957
+ * call replays. `solo_agent` is deliberately ABSENT (measured 2026-10-01 on a
1958
+ * live SG credential, issue #19): it is a Remote-agent roster function, not a
1959
+ * chat function. Every model sent with `function: "solo_agent"` — `gpt-5.4`,
1960
+ * `gpt-5.6-sol`, `gpt-6-astra`, `glm-5.2`, `kimi-k2.7-code` — answered
1961
+ * `event:error {"code":4011}` (message says "rate limit", it is really "not
1962
+ * served here"), while the same model with the same body answered normally
1963
+ * under `solo_work_remote` / `solo_work_lite` / `solo_agent_remote`.
1964
+ *
1965
+ * Note `get_detail_param` DOES answer `solo_agent` with a 43-entry list, so
1966
+ * asking it looks helpful while making 8 of the 10 exposed ai-region models
1967
+ * uncallable: it won precedence and stamped its own name onto configs it
1968
+ * cannot serve. `solo_agent_remote` is the chat-callable half of that roster
1969
+ * (it answers `minimax-m3`, `gemini-3.1-pro`, … that no `solo_work_*` lists)
1970
+ * and therefore takes the last slot — after the two IDE functions that serve
1971
+ * their shared configs without the Remote plan gate.
1918
1972
  */
1919
1973
  const TRAE_DIRECTORY_FUNCTIONS = {
1920
1974
  cn: ["solo_work_remote", TRAE_SOLO_FUNCTION],
1921
1975
  ai: [
1922
- "solo_agent",
1923
1976
  "solo_work_remote",
1924
- TRAE_SOLO_FUNCTION
1977
+ TRAE_SOLO_FUNCTION,
1978
+ "solo_agent_remote"
1925
1979
  ]
1926
1980
  };
1927
1981
  const TRAE_SOLO_CHAT_PATH = "/api/agent/v3/llm_utils_chat";
@@ -2268,6 +2322,75 @@ function decodeTraeEvent(event) {
2268
2322
  }
2269
2323
  //#endregion
2270
2324
  //#region src/solo-bridge.ts
2325
+ /**
2326
+ * What Trae's in-stream error codes mean. Measured 2026-10-01 on a live SG
2327
+ * credential (issue #19), by sending the same body to several functions:
2328
+ *
2329
+ * - `1005` — the model needs a paid plan. Trae answers HTTP 200 with
2330
+ * `event:error {"code":1005,"message":"","extra":"{\"plan\":1}"}`, so the
2331
+ * gate is only visible in `extra.plan`; `gpt-6-sol` returned it under both
2332
+ * `solo_work_remote` and `solo_agent_remote` on a free account.
2333
+ * - `4011` — the function the request named does not serve that model. Its
2334
+ * message claims "exceeded the rate limit", which is misleading: the same
2335
+ * model answers normally when sent under `solo_work_remote`.
2336
+ * - `4001` — `config_name` is not one this function serves.
2337
+ */
2338
+ const TRAE_ERROR_HINTS = {
2339
+ 1005: "Trae requires a paid plan for this model; the current account's plan does not cover it",
2340
+ 4001: "Trae does not serve this model under the SOLO function the request used (config_name rejected)",
2341
+ 4011: "Trae refused this model under the SOLO function the request used"
2342
+ };
2343
+ /** `extra` is a JSON *string* on the wire; unwrap it to read `plan`. */
2344
+ function traeErrorPlan(extra) {
2345
+ let value = extra;
2346
+ if (typeof value === "string") try {
2347
+ value = JSON.parse(value);
2348
+ } catch {
2349
+ return;
2350
+ }
2351
+ if (typeof value !== "object" || value === null) return void 0;
2352
+ const plan = value["plan"];
2353
+ return typeof plan === "number" ? plan : void 0;
2354
+ }
2355
+ /** A readable message for an in-stream Trae failure, with its code preserved. */
2356
+ function describeTraeFailure(failure) {
2357
+ const plan = traeErrorPlan(failure.extra);
2358
+ const parts = [
2359
+ failure.code === void 0 ? "Trae refused the request" : TRAE_ERROR_HINTS[failure.code] ?? "Trae refused the request",
2360
+ ...plan === void 0 ? [] : [`plan ${plan}`],
2361
+ ...failure.code === void 0 ? [] : [`Trae code ${failure.code}`]
2362
+ ];
2363
+ const detail = failure.message.trim();
2364
+ return detail === "" ? parts.join(" · ") : `${parts.join(" · ")} · upstream: ${detail}`;
2365
+ }
2366
+ /**
2367
+ * HTTP status + error kind for an in-stream failure, so the loopback shim can
2368
+ * answer with a real error instead of a truncated 200 stream.
2369
+ *
2370
+ * `1005` is a subscription gate: 402 is what makes clients stop rather than
2371
+ * retry, and it is truthful. Everything else is a client-side rejection.
2372
+ */
2373
+ function classifyTraeFailure(failure) {
2374
+ return failure.code === 1005 ? {
2375
+ status: 402,
2376
+ kind: "hard_credit"
2377
+ } : {
2378
+ status: 400,
2379
+ kind: "client"
2380
+ };
2381
+ }
2382
+ /** Recognise an in-stream failure event, or return undefined for anything else. */
2383
+ function traeFailureOf(decoded) {
2384
+ if (decoded.type !== "unknown") return void 0;
2385
+ const payload = decoded.data;
2386
+ const code = typeof payload?.["code"] === "number" ? payload["code"] : void 0;
2387
+ if (decoded.event !== "error" && !(code !== void 0 && code >= 4e3)) return void 0;
2388
+ return {
2389
+ ...code === void 0 ? {} : { code },
2390
+ message: typeof payload?.["message"] === "string" ? payload["message"] : "",
2391
+ ...payload?.["extra"] === void 0 ? {} : { extra: payload["extra"] }
2392
+ };
2393
+ }
2271
2394
  function normalizeToolCalls(value) {
2272
2395
  if (!Array.isArray(value)) return [];
2273
2396
  const calls = [];
@@ -2292,6 +2415,10 @@ function normalizeToolCalls(value) {
2292
2415
  function bridgeTraeSoloStream(response, model) {
2293
2416
  const source = response.body;
2294
2417
  if (source === null) return new Response(null, { status: 502 });
2418
+ return bridgeTraeSource(source, model);
2419
+ }
2420
+ /** {@link bridgeTraeSoloStream} over an already-opened byte stream. */
2421
+ function bridgeTraeSource(source, model) {
2295
2422
  const id = `chatcmpl-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
2296
2423
  const created = Math.floor(Date.now() / 1e3);
2297
2424
  const decoder = new TextDecoder();
@@ -2320,9 +2447,8 @@ function bridgeTraeSoloStream(response, model) {
2320
2447
  const consume = (event) => {
2321
2448
  const decoded = decodeTraeEvent(event);
2322
2449
  if (decoded.type === "unknown") {
2323
- const payload = decoded.data;
2324
- const code = typeof payload?.["code"] === "number" ? payload["code"] : void 0;
2325
- if (decoded.event === "error" || code !== void 0 && code >= 4e3) upstreamError = new Error(typeof payload?.["message"] === "string" && payload["message"] !== "" ? payload["message"] : `Trae upstream error (code ${code ?? "?"})`);
2450
+ const failure = traeFailureOf(decoded);
2451
+ if (failure !== void 0) upstreamError = new Error(describeTraeFailure(failure));
2326
2452
  return;
2327
2453
  }
2328
2454
  if (decoded.type === "delta") {
@@ -2392,6 +2518,123 @@ function bridgeTraeSoloStream(response, model) {
2392
2518
  headers: { "Content-Type": "text/event-stream" }
2393
2519
  });
2394
2520
  }
2521
+ /**
2522
+ * How far {@link openTraeSource} reads before giving up on finding a failure.
2523
+ * Trae opens a real answer with small `metadata` / `timing_cost` events, so a
2524
+ * healthy stream is recognised well inside these bounds; they exist only so a
2525
+ * pathological stream cannot stall the response headers.
2526
+ */
2527
+ const PEEK_MAX_CHUNKS = 64;
2528
+ const PEEK_MAX_BYTES = 262144;
2529
+ /** Replay the bytes the peek consumed, then drain the rest of the upstream. */
2530
+ function replayTraeSource(buffered, reader, ended) {
2531
+ let index = 0;
2532
+ return new ReadableStream({
2533
+ async pull(controller) {
2534
+ const chunk = buffered[index];
2535
+ if (chunk !== void 0) {
2536
+ index += 1;
2537
+ controller.enqueue(chunk);
2538
+ return;
2539
+ }
2540
+ if (ended) {
2541
+ controller.close();
2542
+ return;
2543
+ }
2544
+ const next = await reader.read();
2545
+ if (next.done) {
2546
+ controller.close();
2547
+ return;
2548
+ }
2549
+ controller.enqueue(next.value);
2550
+ },
2551
+ async cancel(reason) {
2552
+ await reader.cancel(reason).catch(() => {});
2553
+ }
2554
+ });
2555
+ }
2556
+ /**
2557
+ * Open the upstream answer, failing fast when Trae refuses BEFORE producing any
2558
+ * output.
2559
+ *
2560
+ * This exists because Trae reports authorisation and subscription failures
2561
+ * inside a **200** SSE body. Relaying that as a 200 and aborting mid-stream
2562
+ * (what this bridge used to do) loses the code at the HTTP boundary: pi-ai only
2563
+ * sees a truncated stream and reports `Stream ended without finish_reason`,
2564
+ * which reads as a network fault and is retried five times — the exact symptom
2565
+ * in issue #19. Peeking until the first real output lets a refusal become a
2566
+ * normal error response with its code and plan intact.
2567
+ *
2568
+ * The peek is limited to the head of the stream and replayed verbatim, so a
2569
+ * healthy answer keeps streaming with no buffering beyond the first event.
2570
+ */
2571
+ async function openTraeSource(response) {
2572
+ const body = response.body;
2573
+ if (body === null) return {
2574
+ ok: false,
2575
+ status: 502,
2576
+ kind: "server",
2577
+ message: "Trae upstream returned no response body"
2578
+ };
2579
+ const reader = body.getReader();
2580
+ const decoder = new TextDecoder();
2581
+ const sse = new SseDecoder();
2582
+ const buffered = [];
2583
+ let bytes = 0;
2584
+ let ended = false;
2585
+ try {
2586
+ while (buffered.length < PEEK_MAX_CHUNKS && bytes < PEEK_MAX_BYTES) {
2587
+ const next = await reader.read();
2588
+ if (next.done) {
2589
+ ended = true;
2590
+ break;
2591
+ }
2592
+ buffered.push(next.value);
2593
+ bytes += next.value.byteLength;
2594
+ let answered = false;
2595
+ for (const event of sse.push(decoder.decode(next.value, { stream: true }))) {
2596
+ const decoded = decodeTraeEvent(event);
2597
+ const failure = traeFailureOf(decoded);
2598
+ if (failure !== void 0) {
2599
+ await reader.cancel().catch(() => {});
2600
+ return {
2601
+ ok: false,
2602
+ ...classifyTraeFailure(failure),
2603
+ message: describeTraeFailure(failure)
2604
+ };
2605
+ }
2606
+ if (decoded.type !== "unknown") answered = true;
2607
+ }
2608
+ if (answered) break;
2609
+ }
2610
+ } catch (error) {
2611
+ reader.releaseLock();
2612
+ return {
2613
+ ok: false,
2614
+ status: 0,
2615
+ kind: "server",
2616
+ message: `transport error: ${String(error)}`
2617
+ };
2618
+ }
2619
+ return {
2620
+ ok: true,
2621
+ source: replayTraeSource(buffered, reader, ended)
2622
+ };
2623
+ }
2624
+ /**
2625
+ * Bridge an upstream answer, turning an early refusal into a real error result.
2626
+ *
2627
+ * The sibling {@link bridgeTraeSoloStream} stays synchronous and always answers
2628
+ * 200; it is the primitive, and this is the policy the bridge class uses.
2629
+ */
2630
+ async function bridgeTraeSoloStreamOrFail(response, model) {
2631
+ const opened = await openTraeSource(response);
2632
+ if (!opened.ok) return opened;
2633
+ return {
2634
+ ok: true,
2635
+ response: bridgeTraeSource(opened.source, model)
2636
+ };
2637
+ }
2395
2638
  /** Native SOLO client wrapper used by the loopback OpenAI adapter. */
2396
2639
  var TraeSoloBridge = class {
2397
2640
  upstream;
@@ -2439,10 +2682,7 @@ var TraeSoloBridge = class {
2439
2682
  }
2440
2683
  const result = await this.upstream.chatStream(prepared, signal);
2441
2684
  if (!result.ok) return result;
2442
- return {
2443
- ok: true,
2444
- response: bridgeTraeSoloStream(result.response, model)
2445
- };
2685
+ return await bridgeTraeSoloStreamOrFail(result.response, model);
2446
2686
  }
2447
2687
  };
2448
2688
  //#endregion
@@ -2505,6 +2745,38 @@ function parseTraeRemoteModel(value) {
2505
2745
  //#region src/solo-remote.ts
2506
2746
  const TRAE_SOLO_REMOTE_BASE = "https://solo.trae.cn/api/remote/v1";
2507
2747
  /**
2748
+ * Remote-directory functions to ask for, per region.
2749
+ *
2750
+ * The remote `/models` answer is **grouped by function** and the groups are not
2751
+ * interchangeable (measured 2026-10-01, issue #19):
2752
+ *
2753
+ * ```
2754
+ * solo_agent : 19 models ← superset; also the ONLY source of
2755
+ * gpt-6-astra / gpt-5.6-sol|terra|luna /
2756
+ * glm-5.2 / gpt-5.5 / Seed-2.1-Turbo
2757
+ * solo_agent_remote : 10 models ← what this client used to read, and all it
2758
+ * read: the preferred group only
2759
+ * ```
2760
+ *
2761
+ * `solo_agent` ⊇ `solo_agent_remote`, so reading just the latter hid nine
2762
+ * models that the Trae IDE does show — exactly what the reporter saw. Both
2763
+ * groups are therefore requested, and every returned group is unioned.
2764
+ *
2765
+ * Asking for a function the gateway does not serve is harmless: unknown names
2766
+ * are ignored rather than rejected (`solo_work_lite` returns no group, and the
2767
+ * request still answers HTTP 200). The CN list is left as it was — the CN
2768
+ * rosters were not re-measured here, and the union keeps whichever groups come
2769
+ * back.
2770
+ */
2771
+ const TRAE_REMOTE_DIRECTORY_FUNCTIONS = {
2772
+ cn: ["solo_agent_remote", "solo_work_remote"],
2773
+ ai: [
2774
+ "solo_agent",
2775
+ "solo_agent_remote",
2776
+ "solo_work_remote"
2777
+ ]
2778
+ };
2779
+ /**
2508
2780
  * Region-scoped request dressing. The CN portal is `solo.trae.cn` with the
2509
2781
  * CN locale headers; the international directory lives on the shared
2510
2782
  * `coresg-normal.trae.ai` gateway and was verified (2026-09-15) with the
@@ -2554,16 +2826,16 @@ var TraeSoloRemoteCatalogClient = class {
2554
2826
  const region = regionOfCredential(await this.options.credential());
2555
2827
  const base = this.baseUrl ?? REGION_GATEWAYS[region].remote;
2556
2828
  const headers = await this.headers(region);
2557
- const response = await this.fetchImpl(`${base}/models?functions=solo_agent_remote,solo_work_remote`, {
2829
+ const functions = TRAE_REMOTE_DIRECTORY_FUNCTIONS[region].join(",");
2830
+ const response = await this.fetchImpl(`${base}/models?functions=${functions}`, {
2558
2831
  headers,
2559
2832
  signal: signal ?? AbortSignal.timeout(3e4)
2560
2833
  });
2561
2834
  if (!response.ok) throw new Error(`SOLO remote models returned HTTP ${response.status}`);
2562
2835
  const groups = (await response.json()).data?.list ?? [];
2563
- const preferred = groups.find((group) => group.function === "solo_agent_remote") ?? groups[0];
2564
2836
  const seen = /* @__PURE__ */ new Set();
2565
2837
  const models = [];
2566
- for (const raw of preferred?.models ?? []) {
2838
+ for (const group of groups) for (const raw of group.models ?? []) {
2567
2839
  const model = parseTraeRemoteModel(raw);
2568
2840
  if (model === void 0 || seen.has(model.id)) continue;
2569
2841
  seen.add(model.id);
@@ -3249,6 +3521,8 @@ var TraeUsageClient = class {
3249
3521
  const fissionStart = asNumber(payload["solo_fission_start_time"]);
3250
3522
  const fissionExpire = asNumber(payload["solo_fission_expire_time"]);
3251
3523
  const fissionMax = asNumber(payload["solo_fission_max_usage"]);
3524
+ const payIdentity = asNumber(payload["user_pay_identity"]);
3525
+ const payIdentityStr = typeof payload["user_pay_identity_str"] === "string" && payload["user_pay_identity_str"].trim() !== "" ? payload["user_pay_identity_str"].trim() : void 0;
3252
3526
  return {
3253
3527
  isDollarUsageBilling: flag("is_dollar_usage_billing"),
3254
3528
  hasPackage: flag("has_package"),
@@ -3259,6 +3533,8 @@ var TraeUsageClient = class {
3259
3533
  enableSoloBuilder: flag("enable_solo_builder"),
3260
3534
  enableSoloCoder: flag("enable_solo_coder"),
3261
3535
  enableSoloWeb: flag("enable_solo_web"),
3536
+ ...payIdentity === void 0 ? {} : { payIdentity },
3537
+ ...payIdentityStr === void 0 ? {} : { payIdentityStr },
3262
3538
  ...fissionStart === void 0 || fissionExpire === void 0 || fissionMax === void 0 ? {} : { fission: {
3263
3539
  startTimeMs: fissionStart,
3264
3540
  expireTimeMs: fissionExpire,
@@ -4063,7 +4339,8 @@ const modelConfig = z.object({
4063
4339
  maxTokens: z.number().step(1).min(1),
4064
4340
  input: z.array(z.union(["text", "image"])),
4065
4341
  creditMultiplier: z.number(),
4066
- wireFunction: z.string()
4342
+ wireFunction: z.string(),
4343
+ multimodal: z.boolean()
4067
4344
  });
4068
4345
  const regionStateConfig = z.object({
4069
4346
  enabled: z.boolean().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
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": "2.3.1",
5
+ "version": "2.5.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",
@@ -76,42 +76,44 @@
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@deepseek-ai/cordis": ">=4.0.4 <5.0.0",
79
- "@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.2.0-0",
80
- "@deepseek-ai/dsh-attachment": ">=0.1.7-rc.1 <0.2.0-0",
81
- "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.1 <0.2.0-0",
82
- "@deepseek-ai/dsh-home-paths": ">=0.1.7-rc.1 <0.2.0-0",
83
- "@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.2.0-0",
84
- "@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.2.0-0",
85
- "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.2.0-0",
86
- "@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.2.0-0",
79
+ "@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.3.0-0",
80
+ "@deepseek-ai/dsh-attachment": ">=0.1.7-rc.1 <0.3.0-0",
81
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.1 <0.3.0-0",
82
+ "@deepseek-ai/dsh-home-paths": ">=0.1.7-rc.1 <0.3.0-0",
83
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.3.0-0",
84
+ "@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.3.0-0",
85
+ "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
86
+ "@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
87
87
  "@deepseek-ai/schemastery": ">=3.18.4 <4.0.0",
88
88
  "@earendil-works/pi-ai": ">=0.85.1",
89
89
  "react": "^18.2.0"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@deepseek-ai/cordis": "^4.0.4",
93
- "@deepseek-ai/dsh-atomic-write": "0.1.7-rc.1",
94
- "@deepseek-ai/dsh-attachment": "0.1.7-rc.1",
95
- "@deepseek-ai/dsh-client-locale": "0.1.7-rc.1",
96
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.1",
97
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.1",
98
- "@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.1",
99
- "@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.7-rc.1",
100
- "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.1",
101
- "@deepseek-ai/dsh-home-paths": "0.1.7-rc.1",
102
- "@deepseek-ai/dsh-host-webserver": "0.1.7-rc.1",
103
- "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
104
- "@deepseek-ai/dsh-llm-pi-ai": "0.1.7-rc.1",
105
- "@deepseek-ai/dsh-settings": "0.1.7-rc.1",
93
+ "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.1",
94
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
95
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
96
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
97
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.1",
98
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.1",
99
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "0.2.0-rc.1",
100
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
101
+ "@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
102
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
103
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
104
+ "@deepseek-ai/dsh-llm-pi-ai": "0.2.0-rc.1",
105
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
106
106
  "@deepseek-ai/schemastery": "3.18.4",
107
107
  "@earendil-works/pi-ai": "^0.85.1",
108
108
  "@testing-library/dom": "^10.4.2",
109
109
  "@testing-library/react": "^16.3.3",
110
110
  "@types/node": "^22.20.0",
111
111
  "@types/react": "~18.3.31",
112
+ "@types/semver": "^7.7.0",
112
113
  "jsdom": "^25.0.1",
113
114
  "react": "^18.3.1",
114
115
  "react-dom": "18.3.1",
116
+ "semver": "^7.7.2",
115
117
  "tsdown": "^0.22.2",
116
118
  "typescript": "^5.9.3",
117
119
  "vitest": "^4.1.8"