dsh-model-rotator 0.2.3 → 0.2.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 ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog (dsh-model-rotator)
2
+
3
+ > [简体中文](./CHANGELOG.zh.md) | **English**
4
+
5
+ User-visible changes, newest first. Commit hashes in brackets.
6
+
7
+ ## 0.2.5
8
+ - **Fixed: an exhausted quota read as a rate limit, so the switch never happened.** WorkBuddy answers "可用账号均在限流中,最近一个将于 … 解禁" — every account exhausted. Classified as `rate-limit` it cooled for 60 seconds and retried the same wall; one evening accumulated 702 failures. An account pool that is entirely unavailable is now recognised as `quota` and rests like any other balance wall.
9
+ - **New: the release time is parsed out of the message.** When the provider states when it comes back, that beats any calendar policy. `parseReleaseTime()` accepts `2026/10/18 00:29:52`, `2026-10-18 00:29:52` and ISO forms; a parsed time becomes the exact cooldown (measured here: 60 seconds became 1.7 hours), and only an unparseable message falls back to the next-day rule.
10
+ - **Fixed: a switch was not persisted.** The switch notice was queued and then returned, so `scheduleStatsSave()` never ran — a restart showed the old model still in use. The save happens with the enqueue now.
11
+ - **Improved: a pin defers while it cools.** A model pinned with `model_rotator_select` took the head on every turn even while cooling, so every turn opened by walking into the same wall. The pin now applies only while the model is healthy; the ranking decides during cooldown and the pin returns by itself afterwards. [`TBD`]
12
+
13
+ ## 0.2.4
14
+ - **Docs**: adds this bilingual changelog, listed in the manifest and gated by release-check; the README and repository description were refreshed too. The npm package now carries the changelog with each release. Also fixes a crash in this gate: the SHA check fed a comment line to readFileSync, so the gate died with ENOENT and hid every other check behind it. [`fc14e99`]
15
+
16
+ ## 0.2.3
17
+ - **Fixed**: in the model-list search, a one-character term inside a multi-term query was almost unconstrained (25 of 36 characters matched some name; `a b` returned 264 and `d s` 528) — it is now anchored to the start of a word, while a lone `a` still searches the whole table as a substring. Measured: `a b` 264→2, `d s` 528→42, `flash a` 182→36, `ds fl` unchanged at 309. [`5aec752`]
18
+
19
+ ## 0.2.2
20
+ - **New**: matching now normalises first (case and separators) and ranks hits by quality, with exact matches first.
21
+ - **New**: a length-guarded subsequence fallback, so a query without separators still reaches a hyphenated name (`ds-flash`); a single character does not trigger it. [`b9c9e57`]
22
+
23
+ ## 0.2.1
24
+ - **New**: queries are split on whitespace and matched token by token with AND (previously one substring over the whole query), which sharpens multi-word results considerably across 950 models. [`f04d13f`]
25
+
26
+ ## 0.2.0
27
+ - **New**: a search box for the model roster, with a hit counter and a clear button. [`c691a4f`]
28
+ - **Fixed**: two counters — one read data that was still `null` and showed "1 / 0"; the other walked only the flat model list and reported "no match" while rows were plainly visible when the host omitted that field.
29
+ - **Fixed**: the schema loader accepted a copy on the strength of `object()` alone, and this machine carries a stale copy under the user home without `.volatile()` that was resolved first, so Config threw at load; it now capability-tests the exact chain.
30
+ - **Fixed**: the tests imported the dev tree through a hardcoded absolute path, so the published bytes were never covered by their own suites; all suites are self-relative now. [`6de1b05`]
31
+
32
+ ## 0.1.0 (with the 0.1.x fixes folded in)
33
+ - First release: ranks the models an install has already discovered by measured connectivity and speed, and switches to the next candidate inside the same turn when the current one fails without producing any output; ships a channel-grouped model list with per-channel switches.
34
+ - Fixed: `ctx.llm` is resolved once and reused, so discovery and dispatch survive a deactivated fiber; keep dispatching after deactivation; one candidate per model family and one notice per destination. [`78431f7` `1c52df1`]
35
+ - Fixed: a **plugin-internal** dispatch error was recorded as model evidence and restored as one; the two are now distinguished. [`4a50ba9`]
36
+ - Fixed: a healed stats row was not written back to disk; it is written back, and saved when healed. [`1174bc0`]
37
+ - Fixed: **a host-side refusal is not evidence about the model** — refusals raised at the provider boundary by the harness or another plugin were written into the route's counters, cooling down a channel that had done nothing wrong and overwriting the real provider failure; they are dropped now (no failures, no cooldown, no lastError). [`ac87505`]
38
+ - Fixed: a host-side refusal still went through the whole retry budget and exponential backoff (measured: four dispatches and 1.8s of sleeping); it now stops after one dispatch. [`8c5ec94`]
@@ -0,0 +1,38 @@
1
+ # 更新说明(dsh-model-rotator)
2
+
3
+ > **简体中文** | [English](./CHANGELOG.md)
4
+
5
+ 本文件按版本记录用户可感知的变化。方括号内为对应提交。
6
+
7
+ ## 0.2.5
8
+ - **修复:额度耗尽被误判为限流,导致不切换**。WorkBuddy 的报错是「可用账号均在限流中,最近一个将于 … 解禁」,判成 `rate-limit` 就按 60 秒冷却反复重试同一堵墙(实测一晚累计 702 次失败)。现新增「账号池耗尽」识别归入 `quota`,与「余额不足」同一处置。
9
+ - **新增:解析报错里的解禁时间**。提供方既然写明了何时恢复,就比任何日历策略都准。`parseReleaseTime()` 支持 `2026/10/18 00:29:52`、`2026-10-18 00:29:52` 与 ISO 形式;解析到就用作精确冷却(本次实测把 60 秒变成 1.7 小时),解析不到才回落到次日策略。
10
+ - **修复:切换后未落盘**。切换通知入队后直接 `return`,`scheduleStatsSave()` 没有被调用,所以重启后看不到这次切换发生过。现在入队时一并保存。
11
+ - **改进:pin 在冷却时让位**。此前被 `model_rotator_select` 置顶的模型即使正在冷却也每轮抢在第一位,于是每轮都先撞一次墙再切换。现只在模型健康时置顶,冷却期间交给排序,冷却结束后自动回到首位。[`TBD`]
12
+
13
+ ## 0.2.4
14
+ - **文档**:新增中英双语更新说明(本文件),并纳入发布清单与发布门;README 与仓库首页说明同步更新。npm 包开始随版本携带 CHANGELOG。同时修复发布门自身的一处崩溃:SHA 校验把清单注释行当成文件名读,导致整门以 ENOENT 中止、掩盖其余全部检查。[`fc14e99`]
15
+
16
+ ## 0.2.3
17
+ - **修复**:模型列表搜索里,多词查询中的**单字符词项**几乎无约束(36 个字符里 25 个名字命中;`a b` 返回 264、`d s` 返回 528)⇒ 改为**词首锚定**,单独一个 `a` 仍按子串搜全表。实测 `a b` 264→2、`d s` 528→42、`flash a` 182→36、`ds fl` 309 不变。[`5aec752`]
18
+
19
+ ## 0.2.2
20
+ - **新增**:匹配前先**归一化**(大小写与分隔符),并按**命中质量排序**(精确命中优先)。
21
+ - **新增**:带长度门槛保护的**子序列兜底**——无分隔符查询也能命中连字符名(`ds-flash`),单字符不触发兜底。[`b9c9e57`]
22
+
23
+ ## 0.2.1
24
+ - **新增**:查询**按空格分词、逐词 AND**(此前是整串子串匹配),950 个模型下多词查询命中数大幅收敛。[`f04d13f`]
25
+
26
+ ## 0.2.0
27
+ - **新增**:设置页**模型列表搜索框**,带命中计数与清除按钮。[`c691a4f`]
28
+ - **修复**:命中计数器两处——读仍为 `null` 的数据导致显示「1 / 0」;只遍历扁平模型列表,宿主省略该字段时行明明可见却报「无匹配」。
29
+ - **修复**:schema 加载器只凭 `object()` 就接受副本,而本机 user-home 有一份**缺 `.volatile()` 的旧副本**且被优先命中 ⇒ 配置在加载期抛错;现改为**能力探测**。
30
+ - **修复**:测试用硬编码绝对路径导入开发树,导致**发布字节从未被自己的套件覆盖**;现全部改为相对自身。[`6de1b05`]
31
+
32
+ ## 0.1.0(含 0.1.x 修复合入)
33
+ - 首个版本:按实测连通性与速度**排序已发现模型**,当前模型无输出失败时在**同一回合内**切到下一个候选;附按渠道分组的模型列表与逐渠道开关。
34
+ - 修复:`ctx.llm` 只解析一次并复用,使发现与派发在 fiber 失活后仍可用;失活后继续派发;每个模型家族只取一个候选、每次只发一条通知。[`78431f7` `1c52df1`]
35
+ - 修复:**插件内部**的派发错误被当作模型证据记录并据此恢复 ⇒ 现区分开来。[`4a50ba9`]
36
+ - 修复:修复过的统计行未写回磁盘 ⇒ 现写回并在修复时保存。[`1174bc0`]
37
+ - 修复:**宿主侧的拒绝不是模型证据**——宿主/其他插件在 provider 边界的拒绝此前被写进路由计数,使**无辜渠道被冷却、显示为坏**,还会覆盖真正的 provider 失败;现直接丢弃(不计失败、不冷却、不留 lastError)。[`ac87505`]
38
+ - 修复:宿主侧拒绝此前仍走满重试预算与指数退避(实测 4 次派发 + 1.8 s 睡眠)⇒ 现一次派发即止。[`8c5ec94`]
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-model-rotator
2
2
 
3
- > [简体中文](./README.zh.md) | **English**
3
+ > [简体中文](./README.zh.md) | **English** | [Changelog](./CHANGELOG.md)
4
4
 
5
5
  Model auto-ranking and transparent failover for DeepSeek Harness.
6
6
 
package/README.zh.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-model-rotator · 模型自动排序与故障转移
2
2
 
3
- > **简体中文** | [English](./README.md)
3
+ > **简体中文** | [English](./README.md) | [更新说明](./CHANGELOG.zh.md)
4
4
 
5
5
  给 DeepSeek Harness 装上一个「换模型的自动驾驶」:当前模型撞上额度墙、密钥失效、被限流,或者网络刚好抖动时,插件会在**同一轮对话之内**换到下一个最合适的已配置模型,继续把这轮回答完——用户看到的仍然是一条普通回复,不会中途断掉。
6
6
 
package/index.js CHANGED
@@ -703,6 +703,12 @@ const FAILURE_KIND_TESTS = [
703
703
  // that classification would cost the daily rest a dead balance needs.
704
704
  ['unsupported', /(does not support|doesn't support|unsupported|not supported|no such model|unknown model|model not found|invalid model|model is not available)/i],
705
705
  ['auth', /(invalid[ _-]*api[ _-]*key|api[ _-]*key[ _-]*(invalid|missing|expired)|unauthori[sz]ed|unauthenticated|authentication|credential|permission denied|forbidden|401|403|鉴权|密钥|授权失败|认证失败)/i],
706
+ // An account pool that is entirely unavailable is a quota wall wearing the
707
+ // word "rate limit". WorkBuddy answers "可用账号均在限流中,最近一个将于 … 解禁" —
708
+ // every account exhausted, with the release timestamp in the sentence. Classified
709
+ // as rate-limit it cooled for 60s and retried the same wall 702 times in one
710
+ // evening; as quota it rests until the pool actually comes back.
711
+ ['quota', /(?:可用)?账号[^。,,;;]{0,6}(?:均|全部|都)[^。,,;;]{0,8}(?:限流|不可用|耗尽|用尽)|均在限流|均不可用|all accounts?(?:[ _-]+\w+){0,3}[ _-]+(?:exhausted|unavailable|rate[ _-]*limited)|every account[ _-]+(?:is[ _-]+)?(?:exhausted|unavailable)/i],
706
712
  ['rate-limit', /(rate[ _-]*limit|too many requests|request[ _-]*limit|throttl|429|限流|请求过于频繁|过于频繁|qps)/i],
707
713
  ['server', /(internal[ _-]*server[ _-]*error|bad[ _-]*gateway|service[ _-]*unavailable|server[ _-]*(error|overload)|overloaded|gateway[ _-]*timeout|50[0-9])/i],
708
714
  ['network', /(econnreset|econnrefused|etimedout|enotfound|eai_again|socket|network|fetch failed|timed?[ _-]?out|timeout|disconnected|connection (closed|reset|refused)|aborted)/i],
@@ -768,9 +774,54 @@ export function quotaRestUntil(nowMs, cfg) {
768
774
  return Math.max(boundary.getTime(), nowMs + floor)
769
775
  }
770
776
 
777
+ /**
778
+ * A release time the provider stated in its own words, in epoch ms, or 0.
779
+ *
780
+ * A quota message often says exactly when the pool comes back — WorkBuddy writes
781
+ * "最近一个将于 2026/10/18 00:29:52 解禁". That timestamp is strictly better than any
782
+ * policy we could invent: it is the provider telling us when to try again. Fixed
783
+ * midnight arithmetic ignored it and could either sleep far too long or wake into
784
+ * the same wall.
785
+ *
786
+ * Accepts the shapes that actually appear: `2026/10/18 00:29:52`, `2026-10-18
787
+ * 00:29:52`, and an ISO 8601 stamp. A date with no time is treated as that day's
788
+ * midnight. The value is read in the host's local zone, which is the zone these
789
+ * messages are written in.
790
+ *
791
+ * Returns 0 when nothing parseable is present, so the caller falls back to policy.
792
+ * Exported as a test seam: this is date arithmetic on prose, exactly the kind of
793
+ * thing that should be pinned by an offline assertion.
794
+ */
795
+ export function parseReleaseTime(message) {
796
+ const text = String(message ?? '')
797
+ if (text === '') return 0
798
+ const patterns = [
799
+ /(\d{4})[/-](\d{1,2})[/-](\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?/,
800
+ /(\d{4})[/-](\d{1,2})[/-](\d{1,2})/,
801
+ ]
802
+ for (const re of patterns) {
803
+ const m = text.match(re)
804
+ if (m === null) continue
805
+ const [, y, mo, d, h = '0', mi = '0', sec = '0'] = m
806
+ const at = new Date(
807
+ Number(y), Number(mo) - 1, Number(d),
808
+ Number(h), Number(mi), Number(sec), 0,
809
+ )
810
+ const ms = at.getTime()
811
+ // A provider sentence about the past is a stale copy, not a release time.
812
+ if (Number.isFinite(ms) && ms > 0) return ms
813
+ }
814
+ return 0
815
+ }
816
+
771
817
  /** Instant until which this failure keeps the candidate out of the ranking. */
772
- function cooldownUntilFor(stat, cfg, nowMs, errorKind) {
773
- if (errorKind === 'quota') return quotaRestUntil(nowMs, cfg)
818
+ function cooldownUntilFor(stat, cfg, nowMs, errorKind, message) {
819
+ if (errorKind === 'quota') {
820
+ // The provider's own release timestamp outranks our calendar policy.
821
+ const stated = parseReleaseTime(message)
822
+ if (stated > nowMs) return stated
823
+ return quotaRestUntil(nowMs, cfg)
824
+ }
774
825
  return nowMs + cooldownFor(stat, cfg)
775
826
  }
776
827
 
@@ -1476,9 +1527,18 @@ export function apply(ctx, config = {}, runtime = {}) {
1476
1527
  const ordered = [current, ...byFamily.map(row => row.pair)]
1477
1528
 
1478
1529
  // A pin short-circuits the ranking: the user asked for this head explicitly.
1530
+ //
1531
+ // Unless it is cooling down. A pin is a preference about which healthy model to
1532
+ // try first, not a promise to walk into the same wall every turn: a pinned model
1533
+ // that just failed would be dispatched first on every subsequent turn, fail
1534
+ // again, and make the reader watch the same switch over and over. While it cools
1535
+ // the ranking decides, and the model returns to the head by itself once the
1536
+ // cooldown clears - the pin is not forgotten, only deferred.
1479
1537
  if (pinned !== '' && pinned !== current.provider + '/' + current.model) {
1480
1538
  const at = ordered.findIndex(pair => pair.provider + '/' + pair.model === pinned)
1481
- if (at > 0) ordered.unshift(ordered.splice(at, 1)[0])
1539
+ const pinnedStat = at > 0 ? statFor(pinned) : null
1540
+ const pinnedCooling = pinnedStat !== null && pinnedStat.cooldownUntil > nowMs
1541
+ if (at > 0 && !pinnedCooling) ordered.unshift(ordered.splice(at, 1)[0])
1482
1542
  }
1483
1543
 
1484
1544
  return ordered.slice(0, Math.min(cfg.maxCandidates, HARD_MAX_CANDIDATES))
@@ -1836,6 +1896,11 @@ export function apply(ctx, config = {}, runtime = {}) {
1836
1896
  to: { provider: pair.provider, model: pair.model, displayName: pair.displayName ?? '' },
1837
1897
  reason: classifyFailureKind(String(lastFailure ?? '')),
1838
1898
  })
1899
+ // The switch itself is the event worth persisting. Without this the
1900
+ // counters above were written but the *decision* was not, so a restart
1901
+ // showed the old model still in use and the switch looked like it had
1902
+ // never happened.
1903
+ scheduleStatsSave()
1839
1904
  }
1840
1905
  }
1841
1906
  return
@@ -1880,7 +1945,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1880
1945
  // buries the provider's words under ours.
1881
1946
  if (!hostRefusal || trustedFailure) {
1882
1947
  stat.consecutiveFailures += 1
1883
- stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind)
1948
+ stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind, failedBecause)
1884
1949
  }
1885
1950
  // The error chunk is already in the consumer's hands, so the walk ends
1886
1951
  // here either way - and it must, because a fault of ours would repeat
@@ -1924,7 +1989,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1924
1989
  // route stays fully available to the next turn.
1925
1990
  if (!hostRefusal || trustedFailure) {
1926
1991
  stat.consecutiveFailures += 1
1927
- stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind)
1992
+ stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind, failedBecause)
1928
1993
  }
1929
1994
 
1930
1995
  // A fault of ours ends the walk. Every further candidate would hit the
@@ -2625,7 +2690,7 @@ export function apply(ctx, config = {}, runtime = {}) {
2625
2690
  const failedKind = classifyFailureKind(failure)
2626
2691
  stat.failures += 1
2627
2692
  stat.consecutiveFailures += 1
2628
- stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind)
2693
+ stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind, failedBecause)
2629
2694
  stat.lastError = failure
2630
2695
  stat.lastErrorKind = failedKind
2631
2696
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-rotator",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Model auto-ranking and transparent failover for DeepSeek Harness: ranks discovered models by measured connectivity and speed, switches to the next candidate inside the same turn when the current one is unusable, and ships a grouped model list with per-channel switches. Never modifies model or provider configuration.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -16,6 +16,8 @@
16
16
  "cordis.patch.yml",
17
17
  "README.md",
18
18
  "README.zh.md",
19
+ "CHANGELOG.md",
20
+ "CHANGELOG.zh.md",
19
21
  "LICENSE",
20
22
  "vendor"
21
23
  ],