dsh-connect-modelscope-token-plan 0.6.3 → 0.6.5

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
@@ -26,6 +26,53 @@
26
26
  > **发版时**:顶部开 `## [X.Y.Z] — YYYY-MM-DD`,完整流程见
27
27
  > [RELEASING.md](RELEASING.md) §3(本文开头这段是**写作者规范**,那是**发布操作流程**)。
28
28
 
29
+ ## [0.6.5] — 2026-10-21
30
+
31
+ **出图:被拒的图片地址不再原样回显**
32
+
33
+ - 输入的图片地址校验不过时,此前原样回显你给的地址——它可能带着签名串或令牌,
34
+ 于是凭据进日志也进面板。现在先脱敏再截断:诊断信息(协议、主机、路径)留着,
35
+ 凭据那部分显示为 [REDACTED]。
36
+
37
+ **面板:计数写盘失败不再悄悄冻住**
38
+
39
+ - 本地计数的写入失败此前被吞掉,面板继续显示上一次成功写下的数字——数字看着在涨,
40
+ 其实早就冻住,且没有半点痕迹。现在写入失败会记一条异常送进面板(并留一条日志),
41
+ 只有真正落盘的写入才算数。
42
+
43
+ **工程:依赖范围放宽**
44
+
45
+ - 插件声明的 AI 运行时依赖范围从 `^0.85.1` 放宽到 `>=0.85.0 <2.0.0`,与当前宿主
46
+ 随附的版本对齐,装上时不再被标成「超出上界」。
47
+
48
+ ## [0.6.4] — 2026-10-19
49
+
50
+ **出图:上游边缘抖动不再杀死任务**
51
+
52
+ - 轮询遇到 5xx / 429 / 408 不再判失败:与传输层抖动同一纪律,在本次等待预算内
53
+ 继续等,等不到才以 `pending` + `jobId` 收场(带 jobId 续查即可,不必重新出图)。
54
+ 此前同一种 CDN 边缘抖动只因表现为 503 就杀作业并进 60s 冷却。4xx 仍是终局。
55
+ - 出图作业表淘汰时跳过仍在续查窗内的作业(越过总放弃线的才优先丢)——能续查的
56
+ 作业被杀,两段式就残缺了;并发 pending 再多也不会杀到活作业。
57
+ - 余额缓存键改用完整令牌指纹(此前只取末 6 位,极小概率把一个身份的余额当成
58
+ 另一个的返回)。无界面变化。
59
+
60
+ **面板:本地计数的依赖说在明面上**
61
+
62
+ - 未开启「接入为 DSH 模型」时,「本地」区块现在给出一条提示说明计数从接入后
63
+ 开始记录——此前新装用户看到的是恒为 0/空的四块计数,与「插件坏了」不可区分;
64
+ 开了但没注册成功时提示改说注册状态;provider 状态读不到时不出这条,不替
65
+ Host 断言。
66
+
67
+ **卡片介绍:中英文同源**
68
+
69
+ - 卡片介绍(`locale/zh.json`、`locale/en.json`、`package.json` 的 `description`)此前是三份
70
+ 不同步的文本:中文里「默认关」挂在上下文窗口上(开关关的是模型接入,不是自动填入的窗口),
71
+ 英文另有一段与中文对不上的译文。现在三处与投稿拟稿逐字相同,中文为准、英文逐句对应。
72
+
73
+ > **实施过程(病灶取证、判据、为何两处不改)在
74
+ > [docs/archive/2026-10-19-0.6.4-process.md](docs/archive/2026-10-19-0.6.4-process.md)**。
75
+
29
76
  ## [0.6.3] — 2026-10-19
30
77
 
31
78
  **插件卡头部收敛:「魔搭模型接入」不再在同一屏出现两次**
package/README.md CHANGED
@@ -1,70 +1,66 @@
1
1
  # dsh-connect-modelscope-token-plan
2
2
 
3
- 把魔搭社区(modelscope.cn)**API-Inference 免费额度**的本地用量面板接入 DeepSeek Harness 的 Plugins 页(插件卡内联,三个 tab:额度 / 模型 / 接入),并可选把魔搭注册成 **DSH 的 LLM provider**(id `modelscope-token-plan`,直连 `apiBase`,OpenAI 兼容)——注册后魔搭模型进入 DSH 模型选择器,可直接对话调用。见 [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md)。
3
+ 把魔搭社区(modelscope.cn)**API-Inference 免费额度**的面板接进 DeepSeek Harness 的 **Plugins 页**(插件卡内联,三个 tab:额度 / 模型 / 接入),并可选把魔搭模型注册成 **DSH 的 LLM provider**(id `modelscope-token-plan`,OpenAI 兼容)——注册后魔搭模型进 DSH 模型选择器,可直接对话调用;还能开**出图工具**做文生图 / 图生图。
4
4
 
5
- 姊妹插件:`dsh-connect-sensenova-token-plan`、`dsh-connect-agnes-token-plan`(同族结构,受控复制)。
5
+ 姊妹插件:`dsh-connect-sensenova-token-plan`、`dsh-connect-agnes-token-plan`(同族结构)。
6
6
 
7
- **状态:M4 已落地(provider 注册 + 面板「接入为 DSH 模型」),离线测试与 typecheck/build 全绿。**
7
+ > **三条先知道(省得装完才发现)**
8
+ >
9
+ > 1. **只要一枚 API Key,不要登录**:魔搭访问令牌(形如 `ms-…`,在魔搭「个人中心 > 访问令牌」生成),无 OAuth、无密码、无刷新。`MODELSCOPE_API_KEY` 环境变量只在 DSH 启动时读一次、且优先级高于面板保存值——所以**最省事是直接在面板「接入」tab 保存令牌**(立即生效,不依赖重启)。
10
+ > 2. **面板头条是官方魔粒余额(真实值)**;本地计数只统计**经本插件的调用**,且**按 profile(客户端)分段**——桌面端与 Web 端各记各的账、互不可见(`npm run doctor` 并排列出各 profile)。它不是跨端总量。
11
+ > 3. **接入 provider 与出图工具都默认关**:不打开时插件就是个额度看板;打开任一项失败,面板照常用、只是该模块缺席。
8
12
 
9
13
  ## 面板长什么样
10
14
 
11
15
  三个 tab:**额度 / 模型 / 接入**。
12
16
 
13
- **「额度」tab**——头条是官方魔粒余额(真实值),下面接本地调用分布与趋势图:
17
+ **「额度」tab**——头条是官方魔粒余额(真实值),下面接本地调用分布与趋势图(竖向柱状,可切**显示天数**:默认 / 7 / 14 / 30 / 90):
14
18
 
15
19
  ![「额度」tab:魔粒余额与本地调用](assets/tab-account-and-usage.png)
16
20
 
17
- 趋势图是**竖向柱状图**(每根柱顶标出当天次数、0 次不标;轴约 7 个日期刻度且不带年份),区块内可切**显示天数**(默认 / 7 / 14 / 30 / 90)。这个选择器只决定「画多少天」,与决定「能回看多久」的**保留窗口**(`retentionDays`,默认 90 天)是两件事——显示窗口恒被夹在保留窗口之内,所以不会出现「把已剪掉的日子画成 0」那种与「那天真的没调用」无法区分的形状。选择保存在 `usage-prefs.json`(按 profile 分段)。
21
+ - 本地计数只回答官方余额答不了的问题——按模型分布、本地趋势、429 事件流;它**只读不到时画「—」**,绝不会把「不知道」画成 0。
22
+ - 本地计数**按 profile 分段**(本客户端口径):桌面端与 Web 端是两个 profile,各有各的状态目录,互不可见;直连魔搭的其它客户端不计入。
18
23
 
19
- **「模型」tab**——模型按**厂商分组**(组头可折叠、带数量与已选数,「推荐」徽标挂在组头、推荐的三家置顶),组内一行一个魔搭模型、勾选即启用,顶部可搜索;开关打开即把魔搭注册为 DSH provider:
24
+ **「模型」tab**——模型按厂商分组、勾选即启用,顶部可搜索;开关「接入为 DSH 模型」即把魔搭注册为 DSH provider,模型进入 DSH 选择器:
20
25
 
21
26
  ![「模型」tab:接入为DSH 模型与模型清单](assets/tab-model-list.png)
22
27
 
23
- 同一 tab 下方另有**「出图工具」**卡(v0.2 起):开启后 Host 给 agent 注册
24
- `modelscope_draw_image`,支持文生图、图生图与多图编辑。**它与「接入为 DSH 模型」
25
- 是两条独立开关**,且开关值立即保存、而工具的**挂载发生在下次 Host 启动**(peer 的
26
- tools registry 没有 unregister)。实测协议(异步两段式、必需头、提交态不可信、
27
- 计费与耗时口径)见 [docs/SPIKE-image-gen.md](docs/SPIKE-image-gen.md)。
28
-
29
- 工具本身也是**两段式**的(v0.4 起):一次调用只等 `drawWaitMs`(默认 90 秒)——
30
- 图好了直接返回,没好就返回 `status: pending` 与一个 `jobId`,**带上它再调用即可
31
- 继续查询**(只查询、不重复出图)。上游排队实测 1–15 分钟,此前一次调用会一直
32
- await 到出图为止,整轮对话都得陪着等。
33
-
34
- ![「模型」tab:出图工具卡(能力徽标、自动选择、重启提示)](assets/tab-model-draw.png)
35
-
36
- **卡片最底**另有**「上下文窗口覆盖」**选择器(不覆盖 / 128K / 256K / 512K / 1M):
37
- 魔搭目录 `/v1/models` 不返回窗口字段,Host 侧按四级真值链给每个模型定窗口——
38
- ① 目录真声明了就用目录值(不受任何下层影响);② 用户在下面选一个档位(保存即
39
- 重注册);③ 没设覆盖才查内置策展表(`context-catalog.ts`,来自 models.dev 各厂 host
40
- 交叉比对的同 id 主流窗口,2026-10-16 核对,含 DeepSeek-V4 全系 1M、
41
- Qwen3.5/Qwen3.8-27B/Step 3.x 256K、GLM-5.2 1M 等 19 个当前目录 id);④ 都没有才落
42
- 128K 兜底。**覆盖压过策展表**——插件自带的那份是第三方交叉真值,用户要改就改得动。
43
- 档位落盘在 `provider.json` 的 `contextWindow` 键(v1 形状的新增可选键,旧构建宽容读),
44
- roster 每行标出该模型当前生效的窗口,数字按**上游写出来的进制**显示(目录写 `128000`
45
- 就是 `128K`、档位写 `131072` 也是 `128K`,不做进制换算;策展表来的值带 `*` 号与悬停
46
- 说明——平台未亲口声明)。
28
+ - 同一 tab 下方有**「出图工具」**卡:开启后支持文生图、图生图与多图编辑。它与「接入为 DSH 模型」是两条独立开关,开关值立即保存、**工具的挂载发生在下次 Host 启动**。出图是异步的:一次没出好会返回 `status: pending` 与 `jobId`,带上它再调一次即可续查(不必重出)。协议细节见 [docs/SPIKE-image-gen.md](docs/SPIKE-image-gen.md)(2026-10-10 实测,v0.2 起已实现)。
29
+ - 卡片底部有**「上下文窗口覆盖」**选择器(不覆盖 / 128K / 256K / 512K / 1M):魔搭目录不返回窗口字段,插件按「目录声明 → 你选的档位 → 内置策展表(models.dev 交叉真值)→ 128K 兜底」四级兜底;覆盖压过策展表。档位来源与「为什么不给输出上限内置策展表」见 [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md)。
30
+
31
+ **「接入」tab**——粘贴一枚 `ms-…` 访问令牌即完成凭据配置(令牌永不入日志、永不进插件目录)。没有令牌时面板会提示你到这里来填。
47
32
 
48
33
  ## 对话框胶囊(v0.2 起)
49
34
 
50
- 注册 provider 之后,输入框工具栏会多一枚胶囊(用户不需要打开 Plugins 页就能看见额度):
35
+ 注册 provider 之后,输入框工具栏会多一枚用量胶囊,不用打开 Plugins 页也能看到额度:
51
36
 
52
37
  ```
53
38
  魔搭 · 143 魔粒 · 今日本地 7 次
54
39
  ```
55
40
 
56
- - **前半**是官方魔粒余额(`balance.available`,真实值,与面板头条同一个数);**后半**是今日本地调用次数(Host 侧本地日桶,只统计经本插件的调用)。两个数字来源不同,所以后段必须带「本地」——官方接口没有「今日」这一项,不写清就会被读成官方计数。
57
- - **只在会话当前选中的 provider 是 `modelscope-token-plan` 时出现**(读宿主 session 作用域的 `modelSelection` 投影),切到别的 provider 即整块消失,且一个请求都不发。
58
- - **没有百分比、没有黄色档**。魔粒余额是**存量**、官方没有发布任何上限或低额阈值:画进度条或定一个「低于 N 魔粒算黄」的阈值,都是给一个没人公布过的分母配数。三档只有绿(读数是正数)/ 红(官方读数确实是 0)/ 灰(读不到)。
59
- - **读不到 ≠ 0**(与面板同一条纪律):读不到时该段渲染「—」,0 只在官方真的给了 0 时出现并走告警色;读失败保留上一次的数值,并标「上次数据」(阈值 = Host 声明的 `pollSeconds` × 3)。tooltip 里带余额时间、失败原文与本地计数缺失的原因。
41
+ 实际长这样(输入框工具栏,不用开 Plugins 页):
42
+
43
+ ![对话框用量胶囊:官方魔粒余额 + 今日本地调用次数](assets/modelscope-usage-capsule.png)
44
+
45
+ - 前半是官方魔粒余额(与面板头条同一个数);后半是**今日本地调用次数**(只统计经本插件的调用)——两个数字来源不同,所以后段必须带「本地」。
46
+ - **只在当前选中的 provider 是 `modelscope-token-plan` 时出现**,切到别的 provider 整块消失、不发任何请求。
47
+ - **没有百分比、没有黄色档**:魔粒是存量、官方没有发布上限或低额阈值,造进度条 / 阈值都是给沒人公布过的分母配数。三档只有绿(正)/ 红(官方确为 0)/ 灰(读不到)。
48
+ - **读不到 ≠ 0**:读不到时该段渲染「—」,0 只在官方真的给 0 时出现并走告警色;读失败保留上次数值并标「上次数据」。
49
+
50
+ ## 安装
60
51
 
61
- ## 三条事实(写代码前先认清)
52
+ 1. 在 DSH「插件」页搜索 `dsh-connect-modelscope-token-plan` 点击安装,或运行:
62
53
 
63
- 1. **官方有「魔粒」余额端点**:`GET {siteBase}/openapi/v1/magicubes/balance`(Bearer 访问令牌,匿名 401),返回 `{success, data:{total_balance, available_balance, frozen_amount}}`(2026-10-04 实测,见 [docs/SPIKE.md](docs/SPIKE.md))。推理响应本身仍不带任何额度头。
64
- 2. **本地计数是辅助口径**:面板头条是官方魔粒余额;本地计数只回答官方余额答不了的问题——按模型分布、本地趋势、429 事件流(只统计经本插件的调用,直连魔搭的其它客户端不计入)。次数口径的「推算剩余/参考上限」已从面板移除(官方改魔粒计费后两个单位并排是误导),相应的 `dailyQuotaTotal` / `dailyQuotaPerModel` 两个社区快照常数也从配置里一并删除了——面板不消费它们,留着只会让人以为能算「还剩几次」。
65
- 3. **凭据只有一把静态钥匙**:魔搭访问令牌(个人中心生成,形如 `ms-…`),无 OIDC、无密码、无 refresh。默认读 DSH 凭据服务已有的 `MODELSCOPE_API_KEY` 引用;`MODELSCOPE_API_KEY` 环境变量只在启动时读一次,且优先级高于存储值(坑见下段);令牌永不入日志、永不进插件目录。
54
+ ```bash
55
+ dsh plugin --profile web add dsh-connect-modelscope-token-plan # Web 端
56
+ dsh plugin --profile desktop add dsh-connect-modelscope-token-plan # 桌面端
57
+ ```
66
58
 
67
- **凭据分层有个坑(2026-10-05 实测)**:DSH 凭据服务的优先级里,**启动时的环境快照压过存储文件**,而那个快照**在启动时冻结**(不是活的 `process.env`)。两个后果:①在 DSH 启动**之后**改环境变量——包括 Windows 用户级变量——**不会被看见**,改完必须**完全重启 DSH**(退出全部 `DeepSeek Harness` 进程,不只是关窗口)才进快照;②重启让环境变量生效后,它会**反过来压过面板里保存的值**,而面板仍显示「已保存」。**最省事的做法是直接在面板里保存令牌**(立即生效,且不依赖重启)。
59
+ 2. **完全退出 DSH(含托盘)再启动**。
60
+
61
+ 3. 打开 DSH 的 **Plugins 页**,找到本插件的插件卡(面板是页内联卡片,**不在侧边栏**)。
62
+
63
+ > 装到 npm 的那一份**不含 `docs/`**(`package.json` 的 `files` 白名单只带 `lib/` / `client.js` / `assets/` / `README.md` / `CHANGELOG.md` / `LICENSE` 等),下文指向 `docs/*` 的链接在已安装副本上打不开,请到 GitHub 仓库看。
68
64
 
69
65
  ## 免费额度怎么算(2026-10 快照,以官方为准)
70
66
 
@@ -73,32 +69,43 @@ roster 每行标出该模型当前生效的窗口,数字按**上游写出来
73
69
  - 额度按天重置;耗尽返回 429。
74
70
  - `/v1/models` 免认证可读,**不消耗额度**。
75
71
 
76
- 上面这些数字来自社区公开信息,非官方数据,官方调整后需手动更新本节;**它们只用于解释
77
- 「额度怎么算」,不参与任何计算**——面板不消费次数上限,「还剩几次」算不出来(见下节)。
72
+ 上面这些数字来自社区公开信息、非官方数据,官方调整后需手动更新本节;**它们只用于解释「额度怎么算」,不参与任何计算**——面板不消费次数上限,「还剩几次」算不出来。
78
73
 
79
- ## 文档地图
74
+ ## 运维诊断:这台机器现在开没开 provider?
80
75
 
81
- | 何时 | 查 |
82
- |---|---|
83
- | 「上游到底有没有额度信号」 | [docs/SPIKE.md](docs/SPIKE.md)(实测记录 + 结论) |
84
- | 「能不能接生图、协议是什么」 | [docs/SPIKE-image-gen.md](docs/SPIKE-image-gen.md)(2026-10-10 实测;**v0.2 起已实现**,承上表体例) |
85
- | provider 怎么接入(写代码前) | [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md)(实现契约) |
86
- | **这版是怎么做出来的、踩过什么坑** | [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md)(0.1.0 / M4 实施过程档案) |
87
- | v0.2.0 的十二轮修复细节(已冻结,非入门路径) | [docs/archive/2026-10-05-closeout.md](docs/archive/2026-10-05-closeout.md) |
88
- | 上游端点全景 / 第三方方案对比 | [docs/REFERENCES.md](docs/REFERENCES.md)、[docs/REFERENCE-modelsdev.md](docs/REFERENCE-modelsdev.md) |
89
- | v1 要做什么、做到哪了 | [docs/ROADMAP.md](docs/ROADMAP.md) |
90
- | Host/Client 两半怎么分 | [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md)(§14 红线)+ [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md);本仓库暂无独立的 `ARCHITECTURE.md` |
91
- | 开发者校验工具(变异测试 / 覆盖率) | [tools/dev/README.md](tools/dev/README.md);发布流程见 [RELEASING.md](RELEASING.md) |
76
+ provider / 出图 / 上下文窗口的生效值存在插件私有状态文件里,不在任何配置或路由上,按 profile 分段。查「到底开没开」用 doctor,它只读状态文件、不碰凭据,Host 没起也能跑:
77
+
78
+ ```bash
79
+ npm run doctor # 人读:每个 profile 的 provider / draw 开关与模型清单
80
+ npm run doctor:json # 机器读:JSON(可进你的巡检 / 工单脚本)
81
+ ```
92
82
 
93
83
  ## 诚实声明
94
84
 
95
- - 面板头条是**官方魔粒余额**(真实值);本地计数只统计**经本插件的调用**——它回答的是分布/趋势/429 事件流,不是余额。**它是「本客户端」的口径**:注册 provider 之后该客户端的全部魔搭调用都经本插件,但**不跨客户端**——桌面端与 Web 端是两个 profile,各有各的状态目录(`$DSH_HOME/state/<profile>/<插件名>/usage.json`),各记各的账、互不可见;直连魔搭的其它客户端同样不计入。`npm run doctor` 会把每个 profile 的用量并排列出来,这是「另一个客户端的数字去哪了」的答案;迁移窗口期(旧共享目录与 profile 目录并存)它也会把旧共享布局以 `shared (legacy)` 作用域报出。次数口径的「推算剩余」已从面板移除(官方改魔粒计费后两个单位并排是误导),相应地`quota.daily` 只剩 `usedLocal` 一个数——没有 `limit`,也没有推算出的「剩余」。这个数还有第三种答案:**本地计数文件读不到时它是 `null`,面板渲染「—」**——「今日 0 次」是可信的日常值,把「我不知道」画成它会让人以为今天没调用过(`usage.json` 损坏、形状不对、或被更新的构建写过都是这种情形,原因会在面板上方的漂移提示里说明)。
96
- - **本地计数的口径与 harness 全局账本不同,且刻意不合并**。DSH 自己有 `@linxin666/dsh-usage`,按 `days→provider→model` 记成功调用的 token;它只订阅 session 的 `assistant/message`,**失败调用一次都不记**。所以本插件只做两件全局账本答不了的事:①「经本插件的调用分布与趋势」;②**429/错误事件流**——这是本插件的独家数据(官方只给总额度余额,不给「哪个模型刚才在限频」)。成功的 token 总量不在此重复记账(详见 `src/host/usage-observer.ts` 文件头)。
97
- - 429/错误事件的分类读的是 **`llm-error-fix` 重分类之后**的 code:被误判成 QUOTA 的 rpm 限频在事件流里记作 `rate_limit`,与退避策略说同一件事。观察层套在重分类层**之外**,顺序不可反。
98
- - **单模型「额度耗尽」是推断,不是读数**:官方只有账户级魔粒余额,没有 per-model 额度端点。某模型最近一次真耗尽(quota 类 429 事件,重分类后的口径;瞬时限频不算)落在推断窗口内(`exhaustionWindowMinutes`,默认 60 分钟,5–1440 可调)时,picker 不再提供它、面板 roster 灰显且禁勾选;事件移出窗口即恢复。**恢复不可观测是已知代价**——成功的调用不发事件,额度若在窗口内恢复(按天重置恰好如此),模型最多灰显满一个窗口:方向保守(不提供注定 429 的模型),且灰显带着原因,不是静默消失。判定规则与接线见 [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md) §15。
99
- - **计数口径**:每次流结束记一次 call,**含失败、含用户中断**(魔搭按次数计费,限频掉的请求同样是一次调用);token 数上游没给时是 `null` 而非 0。偏差方向是**按流计次**:重试不在流内部发生,而是在流消费完后由 `agent/request-error` 瀑布决定重发,因而**每次重试各记一次**——按「魔搭按次数计费」的口径这反而是对的(每次重发都真打了一次上游)。
100
- - **provider 默认关**(opt-in,与姊妹插件一致):面板「模型」tab 的「接入为 DSH 模型」开关翻转即生效,面板保存值优先于 `cordis.patch.yml` 的 `registerProvider` 默认。
101
- - **已知限制:`reasoning` 恒为 false**。魔搭 API-Inference 是多模型代理,是否吃 `reasoning_effort` 取决于背后那个模型,本插件保守默认不发该参数(模型用自己的默认),也不提供思考强度选择器。**原理由「发错档位会整条请求 400」已在 2026-10-05 真机探测中证伪**:`reasoning_effort` 传 `"bogus"` 同样返回 200,值不被校验。真实障碍是无法区分「生效」与「静默忽略」(都是 200),且该端点会在真响应与空壳 200 之间摇摆——档位表做不出来,不是没做。见 [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md) §4。
102
- - **输出上限默认是 harness 的 32768,不是魔搭声明的数**。魔搭 `/v1/models` 从不返回输出上限字段(实测每条只有 `id`/`object`/`owned_by`/`created`),而本插件对输出上限的策略是「目录声明了才声明」——两条合起来:**每一个**模型的 descriptor 都不带 `maxTokens`,harness 于是用它的 `defaultMaxTokens ?? 32768` 兜底。32768 不是魔搭声明的数、也不是实测过的数,它只是 harness 的缺省值;目录给不出真值时,只有你知道某个模型实测能吐多少。出口是 `cordis.patch.yml` 的 `maxTokensOverrides`(**精确**模型 id → 正整数),优先级 `覆盖 > 目录声明 > 不声明`;面板模型清单行用「最大输出」徽章显示**生效值**(来源是覆盖还是目录写在 tooltip 里),**没带徽章 = 正在吃 32768 兜底**——「没显示」不等于「没上限」,这是本面板最容易读反的一处。**为什么不像上下文窗口那样内置一张策展表**:窗口有 models.dev 的交叉真值可引,输出上限平台从不声明、也没对它做过逐模型实测,策展一张「猜每个模型能吐多少」的表就是在编数字。
103
- - 429 响应形状未实测(不值得烧额度去触发),解析按 OpenAI 惯例兼容,漂移时面板会给 `shapeWarnings`。
104
- - 面板「模型」tab 的每个模型都外链到其魔搭详情页(`https://www.modelscope.cn/models/{owner}/{model}`):魔粒单价只在网页展示、不在官方 API,所以这只是一个**人工核对用的跳转,不是数据源**(「为什么没有按模型消耗 API」见 [docs/REFERENCES.md](docs/REFERENCES.md))。
85
+ - 面板头条是**官方魔粒余额**(真实值);本地计数只统计**本客户端(本 profile)内经本插件的调用**——它回答分布/趋势/429 事件流,不是余额,且桌面端与 Web 端各记各的、互不可见(迁移窗口期旧共享布局以 `shared (legacy)` 报出)。读不到时渲染「—」,不是 0。
86
+ - **本地计数与 harness 全局账本刻意不合并**:DSH 自己的 `@linxin666/dsh-usage` 只记成功调用的 token、且失败一次都不记;本插件只补它答不了的两件事——① 经本插件的分布与趋势;② **429 / 错误事件流**(官方只给总额度余额,不给「哪个模型刚才在限频」,这是本插件的独家数据)。
87
+ - 429 / 错误事件读的是 `llm-error-fix` 重分类之后的 code;观察层套在重分类层之外,顺序不可反。
88
+ - **单模型「额度耗尽」是推断,不是读数**:官方只有账户级魔粒余额,没有 per-model 额度端点;最近一次真耗尽落在推断窗口内时 picker 不再提供它、面板灰显禁勾选,事件移出窗口即恢复(恢复不可观测是已知代价)。判定见 [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md) §15。
89
+ - 计数口径:每次流结束记一次 call,**含失败、含用户中断**;token 数上游没给时是 `null` 而非 0。重试各记一次(魔搭按次数计费,每次重发都真打了一次上游;完整论述见 `src/host/usage-observer.ts`)。
90
+ - **provider 默认关**(opt-in):面板「模型」tab 的「接入为 DSH 模型」开关翻转即生效,面板保存值优先于 `cordis.patch.yml` 的默认。
91
+ - **已知限制:`reasoning` 恒为 false**:本插件保守默认不发 `reasoning_effort`(模型用自己的默认),也不提供思考强度选择器——值不被校验、且无法区分「生效」与「静默忽略」。完整论证见 [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md) §4。
92
+ - **输出上限默认是 harness 的 32768,不是魔搭声明的数**:魔搭 `/v1/models` 从不返回输出上限字段,本插件策略是「目录声明了才声明」,所以每个模型的 descriptor 都不带 `maxTokens`,harness 用缺省 32768 兜底;面板「最大输出」徽章显示生效值,**没带徽章 = 正在吃 32768 兜底**。精确覆盖在 `cordis.patch.yml` 的 `maxTokensOverrides`。为什么不像上下文窗口那样内置策展表:平台从不声明、也没逐模型实测,策展一张「猜能吐多少」的表就是在编数字(详见 [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md))。
93
+ - 次数口径的「推算剩余 / 参考上限」已从面板移除(官方改魔粒计费后两个单位并排是误导),配置里的 `dailyQuotaTotal` / `dailyQuotaPerModel` 两个社区快照常数也一并删除——面板不消费它们,留着只会让人以为能算「还剩几次」。
94
+ - 429 响应形状未实测,解析按 OpenAI 惯例兼容,漂移时面板给 `shapeWarnings`。
95
+ - 面板「模型」tab 的每个模型外链到其魔搭详情页(单价只在网页展示、不在官方 API),所以只是**人工核对用的跳转,不是数据源**。
96
+
97
+ ## 文档
98
+
99
+ - [docs/SPIKE.md](docs/SPIKE.md) — 上游到底有没有额度信号(实测记录 + 结论)
100
+ - [docs/SPIKE-image-gen.md](docs/SPIKE-image-gen.md) — 出图协议实测(2026-10-10;v0.2 起已实现)
101
+ - [docs/PROVIDER-M4.md](docs/PROVIDER-M4.md) — provider 接入实现契约(设计裁定都在这里)
102
+ - [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) — 这版怎么做出来的、踩过什么坑
103
+ - [docs/REFERENCES.md](docs/REFERENCES.md)、[docs/REFERENCE-modelsdev.md](docs/REFERENCE-modelsdev.md) — 上游端点全景 / 第三方方案对比
104
+ - [docs/ROADMAP.md](docs/ROADMAP.md) — v1 要做什么、做到哪了
105
+ - [docs/archive/](docs/archive/) — 已冻结的逐版修复档案
106
+
107
+ AI 协作会话请先读 [AGENTS.md](AGENTS.md)(验证入口、红线与文档地图);开发者校验工具(变异测试 / 覆盖率)见 [tools/dev/README.md](tools/dev/README.md);发布流程见 [RELEASING.md](RELEASING.md)。
108
+
109
+ ## 许可证
110
+
111
+ MIT License,全文见 [LICENSE](LICENSE)。
package/client.js CHANGED
@@ -174,6 +174,8 @@ var dsh_connect_modelscope_token_plan_client = (function() {
174
174
  "quota.rowTokens": "{tokens} token",
175
175
  "quota.note": "本地口径:只统计本客户端经本插件的调用(桌面端与 Web 端各记各的);直连魔搭的其它客户端不计入。消耗总量以官方魔粒余额为准。",
176
176
  "quota.unavailable": "本地计数本次读不到(原因见上方提示)",
177
+ "quota.countingOff": "本地计数只统计接入后的调用:当前未开启「接入为 DSH 模型」(开关在「模型」tab),开启后开始记录。",
178
+ "quota.countingPending": "「接入为 DSH 模型」已开启但尚未注册成功(见「模型」tab 状态),注册生效后本地计数开始记录。",
177
179
  "probe.validity": "验令牌(零额度)",
178
180
  "probe.validOk": "令牌有效(HTTP {status})",
179
181
  "probe.fail": "失败:{error}",
@@ -326,6 +328,8 @@ var dsh_connect_modelscope_token_plan_client = (function() {
326
328
  "quota.rowTokens": "{tokens} tokens",
327
329
  "quota.note": "Local scope: only calls this client made through this plugin are counted (desktop and web keep separate ledgers); clients calling ModelScope directly are not. Total consumption is governed by the official Magicube balance.",
328
330
  "quota.unavailable": "Local counters could not be read this time (reason in the notices above)",
331
+ "quota.countingOff": "Local counts come from calls made through this plugin's provider. \"Access as DSH models\" is off in the Models tab — counts start once it is on.",
332
+ "quota.countingPending": "\"Access as DSH models\" is on but the provider is not registered yet (see the Models tab); local counts start once registration takes effect.",
329
333
  "probe.validity": "Verify token (free)",
330
334
  "probe.validOk": "Token valid (HTTP {status})",
331
335
  "probe.fail": "Failed: {error}",
@@ -1518,8 +1522,12 @@ var dsh_connect_modelscope_token_plan_client = (function() {
1518
1522
  * 刻意**不画**「推算剩余/参考上限」进度条:官方已改为魔粒计费,「2000 次」
1519
1523
  * 是次数口径的社区快照,与魔粒余额并排展示是误导(README「三条事实」#2)。
1520
1524
  * 本地计数回答的是官方余额回答不了的问题:哪个模型在烧、何时撞的 429。
1525
+ *
1526
+ * `countingNote`(已翻译的提示文本,null = 不显示)由 PanelPage 给:本地计数的
1527
+ * 唯一生产者挂在注册 provider 后的适配器上,未接入时这张卡恒为 0/空——那件事
1528
+ * 必须说出口(2026-10-19 评审落地)。
1521
1529
  */
1522
- function LocalDailyCard({ snapshot, tt }) {
1530
+ function LocalDailyCard({ snapshot, countingNote = null, tt }) {
1523
1531
  const quota = snapshot.quota;
1524
1532
  const daily = quota?.daily ?? { usedLocal: null };
1525
1533
  const perModel = quota?.perModel;
@@ -1536,7 +1544,10 @@ var dsh_connect_modelscope_token_plan_client = (function() {
1536
1544
  } }, tt("quota.unavailable")) : null, perModelUnknown ? null : h(ModelUsageTable, {
1537
1545
  rows: perModelRows,
1538
1546
  tt
1539
- }), h("div", { style: S.trendLegend }, tt("quota.note")));
1547
+ }), countingNote !== null ? h("div", { style: {
1548
+ ...S.formNote,
1549
+ color: "var(--dsw-alias-state-warn-primary)"
1550
+ } }, countingNote) : null, h("div", { style: S.trendLegend }, tt("quota.note")));
1540
1551
  }
1541
1552
  /** 今日单模型排行(相对最大者画条)。 */
1542
1553
  function ModelUsageTable({ rows, tt }) {
@@ -2119,7 +2130,8 @@ var dsh_connect_modelscope_token_plan_client = (function() {
2119
2130
  *
2120
2131
  * **与 ProviderCard 的关键差异,也是这张卡存在的主要理由**:provider 开关翻转会
2121
2132
  * 立即重注册,而出图工具的开关翻转**要等下次 Host 启动**——peer 的 tools registry
2122
- * 没有 unregister。所以这张卡必须把这件事说出来(`draw.restartHint`),否则用户
2133
+ * 没有 unregister(完整论述见 src/host/draw-register.ts 文件头注)。所以这张卡必须
2134
+ * 把这件事说出来(`draw.restartHint`),否则用户
2123
2135
  * 翻开关后看不到任何变化,会以为坏了。
2124
2136
  *
2125
2137
  * 缺席原因是**分开渲染**的:`no-tools-service` 是「这个 Host 没提供该服务」(正常),
@@ -2611,6 +2623,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
2611
2623
  cancelled = true;
2612
2624
  };
2613
2625
  }, [hasDrawBlock, pollRevision]);
2626
+ const countingNote = !(hasProviderBlock || fetchedProvider !== null) ? null : provider.enabled !== true ? tt("quota.countingOff") : provider.registered !== true ? tt("quota.countingPending") : null;
2614
2627
  const [providerBusy, setProviderBusy] = useState(false);
2615
2628
  const [providerError, setProviderError] = useState(null);
2616
2629
  const writeErrorText = (reason, t) => reason instanceof Error && reason.name === "AbortError" ? t("common.timeout") : errorText(reason);
@@ -2761,6 +2774,7 @@ var dsh_connect_modelscope_token_plan_client = (function() {
2761
2774
  tt
2762
2775
  }, h(LocalDailyCard, {
2763
2776
  snapshot: snap,
2777
+ countingNote,
2764
2778
  tt
2765
2779
  })), h(SectionCard, {
2766
2780
  title: format(tt("section.trend"), { days: snap.trend?.days ?? 0 }),
package/cordis.patch.yml CHANGED
@@ -62,8 +62,8 @@
62
62
  # 适配器出去,每一次调用与每一条 429/错误事件都由 src/host/usage-observer.ts
63
63
  # 在流的出口记进同一个 usage-store。口径是**本客户端**:桌面端与 Web 端是
64
64
  # 两个 profile,各记各的账、互不可见(`npm run doctor` 并排列出各 profile),
65
- # 别把它当跨端总量。直连魔搭的其它客户端同样不计入(本插件看不见它们),
66
- # 消耗总量以官方魔粒余额为准。
65
+ # 口径的历史与裁定见 docs/PROVIDER-M4.md;别把它当跨端总量。直连魔搭的
66
+ # 其它客户端同样不计入(本插件看不见它们),消耗总量以官方魔粒余额为准。
67
67
  #
68
68
  # 偏差方向是**按流计次**:重试不在流内部发生,而是流被消费完后由
69
69
  # agent/request-error 瀑布决定重发,因而每次重试各记一次(按「魔搭按
@@ -103,7 +103,8 @@
103
103
  # 出图计数——那会变成拿 chat 的口径给生图记账。
104
104
  #
105
105
  # 生效时机:开关值立即保存,但**工具的挂载发生在下次 Host 启动**——
106
- # peer 的 tools registry 没有 unregister,工具一旦注册就摘不下来。
106
+ # peer 的 tools registry 没有 unregister,工具一旦注册就摘不下来(机制与
107
+ # 后果的完整论述见 src/host/draw-register.ts 文件头注)。
107
108
  # 面板会就这一点给出提示(draw.restartHint)。
108
109
  drawEnabled: false
109
110
  # 出图默认模型(空 = 自动取目录里第一个出图模型)。
@@ -1141,7 +1141,7 @@ const inject = ["webServer"];
1141
1141
  * 产物反而引入第二份真源;这里用常量、由 test/config.test.mjs 钉住与
1142
1142
  * package.json 一致。
1143
1143
  */
1144
- const PLUGIN_VERSION = "0.6.3";
1144
+ const PLUGIN_VERSION = "0.6.5";
1145
1145
  /**
1146
1146
  * 默认值。刻意不含额度常数——理由见本文件头。
1147
1147
  * 面板头条是官方魔粒余额,次数口径的推算已在面板收敛时删除,这里若留着
package/lib/index.js CHANGED
@@ -985,6 +985,15 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
985
985
  const file = join(dir, "usage.json");
986
986
  /** 最近一次解析异常(损坏即忽略的痕迹),快照转 shapeWarnings。 */
987
987
  let anomaly = null;
988
+ /**
989
+ * 最近一次**写**失败的痕迹。
990
+ *
991
+ * 与 {@link anomaly} 分开记,因为两者的寿命不同:解析异常是**读**出来的,下一次
992
+ * 读就会覆盖它(读到好的就恢复);写失败是**写**出来的,只有一次成功的写才能
993
+ * 抹掉它——读得动旧值不代表上一次写落了盘(读回来的永远是上一次**成功**写下去
994
+ * 的那一份)。
995
+ */
996
+ let writeAnomaly = null;
988
997
  /** 只读闸:磁盘版本比本构建新时置位——读照常、写拒绝,绝不 clobber。 */
989
998
  let readOnly = false;
990
999
  /** 最近一次确认过的内存态;读路径拿它,写路径以它为基线。 */
@@ -1046,10 +1055,29 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1046
1055
  return parsed.payload;
1047
1056
  };
1048
1057
  const cache = createStateReadCache(readThrough, { now: () => Date.now() });
1049
- /** 把内存态写盘(原子),失败不抛——计数是尽力而为的观测,不是事务。 */
1058
+ /**
1059
+ * 把内存态写盘(原子),失败不抛——计数是尽力而为的观测,不是事务。
1060
+ *
1061
+ * 但「不抛」不等于「不说」。这一句注释原先与实现相反:writeStateFile 失败会**抛**
1062
+ * (state-store.ts 的 writeStateFile 在 catch 里重抛),抛给谁?本仓的用法是
1063
+ * index.ts 那句 `.catch(() => {})`(理由见那里:观测失败不是对话失败)——于是
1064
+ * 一次写失败被彻底咽掉:面板继续显示上一次成功落盘的数字,计数看起来是活的,
1065
+ * 其实早就冻住了,而**没有任何人知道**(没有日志,没有 shapeWarning)。
1066
+ *
1067
+ * 所以失败要留两处痕迹:日志(给操作者)与 {@link writeAnomaly}(进快照的
1068
+ * shapeWarnings,给面板)。吞掉的是失败,不是失败的原因。
1069
+ */
1050
1070
  const persist = async (payload) => {
1051
- await ensureStateDir(dir);
1052
- await writeStateFile(file, JSON.stringify(payload), { temporary: temporaryOf(dir, "usage.json") });
1071
+ try {
1072
+ await ensureStateDir(dir);
1073
+ await writeStateFile(file, JSON.stringify(payload), { temporary: temporaryOf(dir, "usage.json") });
1074
+ } catch (error) {
1075
+ const note = `usage-state: write failed (${redactSecrets(errMsg(error))})`;
1076
+ writeAnomaly = note;
1077
+ logger?.warn?.(`${name}: ${note} — the local counters are not reaching disk; the panel still shows the last values that did`);
1078
+ return;
1079
+ }
1080
+ writeAnomaly = null;
1053
1081
  cache.remember(payload);
1054
1082
  };
1055
1083
  /**
@@ -1266,9 +1294,14 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1266
1294
  if (payload === null) return await pathExists(file) ? null : [];
1267
1295
  return [...payload.events ?? []];
1268
1296
  },
1269
- /** 最近一次解析异常(无异常为 null)——快照转 shapeWarnings。 */
1297
+ /**
1298
+ * 最近一次异常(无异常为 null)——快照转 shapeWarnings。
1299
+ *
1300
+ * 写失败的痕迹优先于读侧的解析异常:它更近、也更严重(解析异常说「这份文件
1301
+ * 读不懂」,写失败说「我们刚写的东西没落盘」)。
1302
+ */
1270
1303
  anomaly() {
1271
- return anomaly;
1304
+ return writeAnomaly ?? anomaly;
1272
1305
  }
1273
1306
  };
1274
1307
  }
@@ -1738,6 +1771,24 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1738
1771
  }
1739
1772
  return Array.from(new Set(tasks));
1740
1773
  };
1774
+ /**
1775
+ * 令牌的稳定指纹(FNV-1a,32 位 hex)——只用作余额缓存的键。
1776
+ *
1777
+ * **不用 `token.slice(-6)` 那种末位截断**:两个不同令牌末 6 位相同的概率在
1778
+ * 1/16^7 量级,虽低但非零,而代价是一个 TTL(cacheSeconds,默认 60s)内把
1779
+ * A 的身份的余额当 B 的返回——缓存键的正确性不该交给运气。指纹不需要保密、
1780
+ * 不进日志(脱敏正则的形状与它无关),完整的哈希零成本。
1781
+ * @param {string} token - 访问令牌。
1782
+ * @returns {string} 8 位 hex 指纹。
1783
+ */
1784
+ function tokenFingerprint(token) {
1785
+ let hash = 2166136261;
1786
+ for (let i = 0; i < token.length; i++) {
1787
+ hash ^= token.charCodeAt(i);
1788
+ hash = Math.imul(hash, 16777619);
1789
+ }
1790
+ return (hash >>> 0).toString(16).padStart(8, "0");
1791
+ }
1741
1792
  return {
1742
1793
  /**
1743
1794
  * 模型目录:GET apiBase/models,免认证、零额度,coalesced 缓存
@@ -1831,13 +1882,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1831
1882
  /**
1832
1883
  * 官方「魔粒」余额:GET siteBase/openapi/v1/magicubes/balance(Bearer
1833
1884
  * 令牌;2026-10-04 实测可用,匿名 401,SPIKE.md §魔粒)。coalesced 缓存
1834
- * cacheSeconds,缓存键带令牌指纹——换令牌 ≤1 个 TTL 内必然读到新身份。
1885
+ * cacheSeconds,缓存键带令牌指纹(FNV-1a 完整哈希,不是末位截断——
1886
+ * 换令牌 ≤1 个 TTL 内必然读到新身份,且不同令牌不会因同后缀串味)。
1835
1887
  */
1836
1888
  async fetchBalance() {
1837
1889
  const { value: token } = await tokenStore.resolve();
1838
1890
  if (token === "") throw pluginError(CODE.AUTH_ERROR, "no ModelScope token configured — set MODELSCOPE_API_KEY or save one in the panel");
1839
1891
  const url = settings.siteBase + "/openapi/v1/magicubes/balance";
1840
- const key = "balance#" + token.slice(-6);
1892
+ const key = "balance#" + tokenFingerprint(token);
1841
1893
  return await read(key, async () => {
1842
1894
  const response = await fetchWithTimeout(url, {
1843
1895
  method: "GET",
@@ -2617,15 +2669,28 @@ function createFileDrawStore(options = {}) {
2617
2669
  //#region src/host/draw-jobs.ts
2618
2670
  /**
2619
2671
  * 建一张出图作业表。
2620
- * @param {DrawJobsOptions} [options] - 容量上限。
2672
+ * @param {DrawJobsOptions} [options] - 容量与保护窗参数。
2621
2673
  * @returns {DrawJobs} 作业表。
2622
2674
  */
2623
2675
  function createDrawJobs(options = {}) {
2624
2676
  const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
2677
+ const resumeWindowMs = Math.max(1, Math.floor(options.resumeWindowMs ?? 9e5));
2678
+ const hardMaxJobs = Math.max(maxJobs, Math.floor(options.hardMaxJobs ?? maxJobs * 2));
2625
2679
  /** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
2626
2680
  const jobs = /* @__PURE__ */ new Map();
2627
- const evictIfNeeded = () => {
2628
- while (jobs.size > maxJobs) {
2681
+ /**
2682
+ * 超容时的两级淘汰(判据见文件头「生命周期」):
2683
+ * 软上限只淘汰已过续查保护窗的(越过总放弃线,续查只换来「超过总预算」);
2684
+ * 硬上限保底——全是保护窗内的作业也绝不无界。
2685
+ * @param {number} now - 当前墙钟毫秒(`create` 的 `now` 缝传进来,测试可造旧作业)。
2686
+ */
2687
+ const evictIfNeeded = (now) => {
2688
+ if (jobs.size <= maxJobs) return;
2689
+ for (const [id, job] of jobs) {
2690
+ if (jobs.size <= maxJobs) return;
2691
+ if (now - job.submittedAt >= resumeWindowMs) jobs.delete(id);
2692
+ }
2693
+ while (jobs.size > hardMaxJobs) {
2629
2694
  const oldest = jobs.keys().next();
2630
2695
  if (oldest.done === true) return;
2631
2696
  jobs.delete(oldest.value);
@@ -2634,18 +2699,19 @@ function createDrawJobs(options = {}) {
2634
2699
  return {
2635
2700
  create({ taskId, model, prompt, now }) {
2636
2701
  const jobId = taskId;
2702
+ const at = typeof now === "number" && Number.isFinite(now) ? now : Date.now();
2637
2703
  const job = {
2638
2704
  jobId,
2639
2705
  taskId,
2640
2706
  model,
2641
2707
  prompt,
2642
- submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
2708
+ submittedAt: at,
2643
2709
  polls: 0
2644
2710
  };
2645
2711
  const previous = jobs.get(jobId);
2646
2712
  if (previous !== void 0) job.polls = previous.polls;
2647
2713
  jobs.set(jobId, job);
2648
- evictIfNeeded();
2714
+ evictIfNeeded(at);
2649
2715
  return { ...job };
2650
2716
  },
2651
2717
  get(jobId) {
@@ -3286,6 +3352,9 @@ function createDrawCooldown(cooldownMs) {
3286
3352
  * 轮询只与更慢相关。
3287
3353
  * 3. 失败原因实测可能**不可知**(`errors.code:0` + 空 message)。`describeDrawFailure`
3288
3354
  * 因此明说「未给出原因」,本文件也**不从空错误里编造**。
3355
+ * 4. 轮询的 5xx / 429 / 408 是**瞬态**不是终局({@link isTransientPollStatus}):
3356
+ * 同一个物理故障不因表现为 TCP 抛错还是 5xx 响应就两种判法;只有 4xx 是
3357
+ * 终局证据。瞬态持续时由 deadline 收口成 pending,续查把手不丢。
3289
3358
  *
3290
3359
  * @module dsh-connect-modelscope-token-plan/draw-runtime
3291
3360
  */
@@ -3393,8 +3462,10 @@ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, image
3393
3462
  * 抛错是「这件事做不成了」,`pending` 是「还没成,可以再查」。同一段循环因此
3394
3463
  * 同时服务于两种调用形状(一次性跑完 / 分段续查),不写两份。
3395
3464
  *
3396
- * 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败);传输层抖动与非
3397
- * JSON 响应体都**不升级为终局**,继续等下一轮。
3465
+ * 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败)。三类情形**不升级
3466
+ * 为终局**,在本次预算内继续等下一轮:① 传输层抛错(ECONNRESET / DNS);
3467
+ * ② 非 JSON / 200 垃圾响应体(CDN 错误页);③ **瞬态 HTTP 状态码**(5xx /
3468
+ * 429 / 408,见 {@link isTransientPollStatus})。只有 4xx 是终局证据。
3398
3469
  * @param {object} options - 等待参数。
3399
3470
  * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
3400
3471
  * @param {string} options.apiBase - apiBase。
@@ -3453,6 +3524,7 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
3453
3524
  }
3454
3525
  const pollText = await safeText(polled);
3455
3526
  if (!polled.ok) {
3527
+ if (isTransientPollStatus(polled.status)) continue;
3456
3528
  const previous = lastStatus !== void 0 ? `(上一轮读到 ${lastStatus})` : "";
3457
3529
  return {
3458
3530
  kind: "failed",
@@ -3741,7 +3813,10 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3741
3813
  const images = [];
3742
3814
  for (const raw of rawImages) {
3743
3815
  const normalized = normalizeImageUrlInput(raw);
3744
- if (normalized === null) throw new Error(`imageUrl 不可用:${raw.slice(0, 80)} —— 只接受公网 http(s) 链接或 data:image/...;base64 内联数据(本地文件上游取不到,请先上传)`);
3816
+ if (normalized === null) {
3817
+ const safe = redactSecrets(raw).slice(0, 80);
3818
+ throw new Error(`imageUrl 不可用:${safe} —— 只接受公网 http(s) 链接或 data:image/...;base64 内联数据(本地文件上游取不到,请先上传)`);
3819
+ }
3745
3820
  images.push(normalized);
3746
3821
  }
3747
3822
  try {
@@ -3805,6 +3880,24 @@ function toPositiveNumber(value) {
3805
3880
  const n = Number(value);
3806
3881
  return Number.isFinite(n) && n > 0 ? n : void 0;
3807
3882
  }
3883
+ /**
3884
+ * 轮询响应里的**瞬态** HTTP 状态码:不判作业死刑,本次等待预算内继续等下一轮。
3885
+ *
3886
+ * 判据是「同一个物理故障不因表现形态而异」:传输层抛错(ECONNRESET / DNS
3887
+ * 解析失败)早就走的是「继续等下一轮」,而同一种 CDN 边缘抖动、上游短暂过载
3888
+ * 以 **5xx/429 响应**的形态出现时,旧写法直接判失败、作业出表、进 60s 冷却
3889
+ * ——agent 想续查只能重新提交一张图。上游排队窗口实测 1–15 分钟
3890
+ * (docs/SPIKE-image-gen.md),那个窗口内的边缘 5xx 是常态噪声,不是终局。
3891
+ *
3892
+ * 4xx 保持终局:401/403 = 令牌或权限真的没了,404/400 = 任务或端点真的没了
3893
+ * ——这些等下去也不会变。deadline 负责收口:瞬态一直持续时耗尽预算后返回
3894
+ * `pending`,两段式的续查把手(jobId)不丢。
3895
+ * @param {number} status - 轮询响应的 HTTP 状态码。
3896
+ * @returns {boolean} 是否按瞬态继续等。
3897
+ */
3898
+ function isTransientPollStatus(status) {
3899
+ return status === 408 || status === 429 || status >= 500;
3900
+ }
3808
3901
  /** 读 body 文本,读不到给空串(错误分诊不能因为读 body 失败而失败)。 */
3809
3902
  async function safeText(response) {
3810
3903
  try {
@@ -5791,7 +5884,8 @@ function registerProviderRoute(ctx, wiring) {
5791
5884
  *
5792
5885
  * 一处**与 provider 路由不同**的地方必须说清:provider 开关翻转会**立即**重注册
5793
5886
  * (publish 是活的重建),而出图工具的开关翻转**要等下次 Host 启动**——peer 的
5794
- * tools registry 没有 unregister,工具一旦注册就摘不下来。所以本路由保存成功后
5887
+ * tools registry 没有 unregister,工具一旦注册就摘不下来(完整论述见
5888
+ * draw-register.ts 文件头注)。所以本路由保存成功后
5795
5889
  * 不改挂载状态,只在响应里给出 `mountPending`,让面板能说清这件事,而不是让用户
5796
5890
  * 以为开关坏了。
5797
5891
  *
@@ -6173,7 +6267,7 @@ function apply(ctx, config = {}, deps = {}) {
6173
6267
  profile,
6174
6268
  logger: ctx.logger
6175
6269
  });
6176
- const drawJobs = createDrawJobs();
6270
+ const drawJobs = createDrawJobs({ resumeWindowMs: settings.drawTimeoutMs });
6177
6271
  const usagePrefsStore = createFileUsagePrefsStore({
6178
6272
  profile,
6179
6273
  logger: ctx.logger
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "ModelScope Model Access",
4
- "description": "Bring the ModelScope language and image-generation models into DSH as default-off switches, with image recognition and context windows filled in automatically. The card also shows the free Magicube balance, links each model to its ModelScope page, and records local call counts, a daily trend and rate-limit events. All of it needs only an API key, with no account sign-in."
4
+ "description": "One-tap connect ModelScope language and image-generation models into DSH (off by default), with image recognition and context window filled in automatically. You can also see the free Magicube balance, jump to a model's info page, and record local call counts, trends and rate-limit events, with a usage pill beside the input box showing the balance and today's local count. All of it needs only an API key, with no account sign-in."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "魔搭模型接入",
4
- "description": "一键接入魔搭社区的语言、生图模型到 DSH ,自动填入图片识别与上下文窗口(默认关);还可查看免费魔粒余额、跳转到模型信息页,记录本地调用计数、趋势、限流事件。上述功能仅需填入 API key ,不需要登陆账号。"
4
+ "description": "一键接入魔搭社区的语言、生图模型到 DSH(默认关),自动填入图片识别与上下文窗口;还可查看免费魔粒余额、跳转到模型信息页,记录本地调用计数、趋势、限流事件,输入框旁另有用量胶囊显示余额与今日本地调用次数。上述功能仅需填入 API key,不需要登录账号。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -2,13 +2,13 @@
2
2
  "name": "dsh-connect-modelscope-token-plan",
3
3
  "displayName": "DSH Connect ModelScope Token Plan",
4
4
  "icon": "./icon.svg",
5
- "version": "0.6.3",
5
+ "version": "0.6.5",
6
6
  "homepage": "https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan",
7
7
  "bugs": {
8
8
  "url": "https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/issues"
9
9
  },
10
10
  "author": "eghrhegpe",
11
- "description": "Bring the ModelScope language and image-generation models into DSH as default-off switches, with image recognition and context windows filled in automatically. The card also shows the free Magicube balance, links each model to its ModelScope page, and records local call counts, a daily trend and rate-limit events. All of it needs only an API key, with no account sign-in.",
11
+ "description": "One-tap connect ModelScope language and image-generation models into DSH (off by default), with image recognition and context window filled in automatically. You can also see the free Magicube balance, jump to a model's info page, and record local call counts, trends and rate-limit events, with a usage pill beside the input box showing the balance and today's local count. All of it needs only an API key, with no account sign-in.",
12
12
  "keywords": [
13
13
  "dsh-plugin",
14
14
  "deepseek-harness",
@@ -45,6 +45,10 @@
45
45
  "test:mutate": "node tools/dev/mutate.mjs tools/dev/mutate-spec.json",
46
46
  "doctor": "node tools/doctor.mjs",
47
47
  "doctor:json": "node tools/doctor.mjs --json",
48
+ "host-range:update": "node tools/update-host-range.mjs",
49
+ "host-range:check": "node tools/update-host-range.mjs --check",
50
+ "market-rules:update": "node tools/check-market-rules.mjs",
51
+ "market-rules:check": "node tools/check-market-rules.mjs --check",
48
52
  "build": "tsdown -c tsdown.config.mjs",
49
53
  "prepack": "npm run build"
50
54
  },
@@ -74,22 +78,48 @@
74
78
  "@deepseek-ai/dsh-settings": ">=0.1.5 <0.3",
75
79
  "@deepseek-ai/dsh-tools": ">=0.1.5 <0.3",
76
80
  "@deepseek-ai/schemastery": "^3.18.2",
77
- "@earendil-works/pi-ai": "^0.85.1"
81
+ "@earendil-works/pi-ai": ">=0.85.0 <2.0.0"
78
82
  },
79
83
  "peerDependenciesMeta": {
80
- "@deepseek-ai/cordis": { "optional": true },
81
- "@deepseek-ai/dsh-client-locale": { "optional": true },
82
- "@deepseek-ai/dsh-client-ui-layout": { "optional": true },
83
- "@deepseek-ai/dsh-client-ui-renderer": { "optional": true },
84
- "@deepseek-ai/dsh-credentials": { "optional": true },
85
- "@deepseek-ai/dsh-home-paths": { "optional": true },
86
- "@deepseek-ai/dsh-host-webserver": { "optional": true },
87
- "@deepseek-ai/dsh-llm": { "optional": true },
88
- "@deepseek-ai/dsh-llm-pi-ai": { "optional": true },
89
- "@deepseek-ai/dsh-settings": { "optional": true },
90
- "@deepseek-ai/dsh-tools": { "optional": true },
91
- "@deepseek-ai/schemastery": { "optional": true },
92
- "@earendil-works/pi-ai": { "optional": true }
84
+ "@deepseek-ai/cordis": {
85
+ "optional": true
86
+ },
87
+ "@deepseek-ai/dsh-client-locale": {
88
+ "optional": true
89
+ },
90
+ "@deepseek-ai/dsh-client-ui-layout": {
91
+ "optional": true
92
+ },
93
+ "@deepseek-ai/dsh-client-ui-renderer": {
94
+ "optional": true
95
+ },
96
+ "@deepseek-ai/dsh-credentials": {
97
+ "optional": true
98
+ },
99
+ "@deepseek-ai/dsh-home-paths": {
100
+ "optional": true
101
+ },
102
+ "@deepseek-ai/dsh-host-webserver": {
103
+ "optional": true
104
+ },
105
+ "@deepseek-ai/dsh-llm": {
106
+ "optional": true
107
+ },
108
+ "@deepseek-ai/dsh-llm-pi-ai": {
109
+ "optional": true
110
+ },
111
+ "@deepseek-ai/dsh-settings": {
112
+ "optional": true
113
+ },
114
+ "@deepseek-ai/dsh-tools": {
115
+ "optional": true
116
+ },
117
+ "@deepseek-ai/schemastery": {
118
+ "optional": true
119
+ },
120
+ "@earendil-works/pi-ai": {
121
+ "optional": true
122
+ }
93
123
  },
94
124
  "engines": {
95
125
  "node": "^22.19.0 || >=24.0.0"
@@ -101,6 +131,7 @@
101
131
  "license": "MIT",
102
132
  "devDependencies": {
103
133
  "@types/node": "^22.19.0",
134
+ "js-yaml": "^5.3.0",
104
135
  "jscpd": "^5.4.0",
105
136
  "jsdom": "^30.1.3",
106
137
  "react": "18.3.1",
package/screenshots.json CHANGED
@@ -1,5 +1,6 @@
1
1
  [
2
2
  "assets/tab-account-and-usage.png",
3
3
  "assets/tab-model-list.png",
4
- "assets/tab-model-draw.png"
4
+ "assets/tab-model-draw.png",
5
+ "assets/modelscope-usage-capsule.png"
5
6
  ]