dsh-model-rotator 0.2.5 → 0.2.7

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
@@ -4,11 +4,21 @@
4
4
 
5
5
  User-visible changes, newest first. Commit hashes in brackets.
6
6
 
7
+ ## 0.2.7
8
+ - **Fixed: a compaction that ran out of quota ended the whole turn.** The compaction summary is the first call a long conversation makes, so when the current model's balance was gone it was the first thing to fail — and it was on the do-not-fail-over list, which meant the refusal was passed straight through and the turn died before the answer existed. Compaction now walks the candidate list like any other dead route: the summary is written by the next model and the turn continues. A session title still stays on the model that was asked, because a caption nobody waits on is not worth a second provider.
9
+ - **Fixed: the switch question no longer parks a summary.** Asking "switch model?" before a compaction would have shown a modal about plumbing the reader is not watching, and on timeout it switched anyway. Compaction switches proceed unconfirmed; the reader still learns about it from the ordinary switch notice at the turn boundary.
10
+ - **Unchanged on purpose: compaction never adopts the switched model.** A summary that fails because the context is too large for this model says nothing about the model's fitness for the conversation. Adoption is left to the conversation attempt, which makes the same call on the right evidence.
11
+ - 168 offline assertions, with three negative controls proving each new exclusion is what makes its assertion pass. [`c5590ef`]
12
+
13
+ ## 0.2.6
14
+ - **Fixed: other channels of the same model were deduplicated out of the walk.** This is the real problem 0.2.5 uncovered once the classification was right: when WorkBuddy met its quota wall, **buddy and trae — the same model on other channels — were healthy**, but "one channel per model family" kept them out of the candidate list entirely, so the turn ended on the quota error with a working route sitting one slot away. A family now keeps up to **two channels**: the ranked winner plus one proven-usable backup. A cooling backup is dropped, since dispatching it would walk into the same wall. The switch notice is already deduplicated by model id, so arriving at a second channel of the same model is not announced as "switched to the same model again".
15
+ - Measured: before the fix the walk held one channel of `deepseek-v4.1-flash` and `buddy` (ok=1538, healthy) was absent; after it holds three and `buddy` is back. [`fed9508`]
16
+
7
17
  ## 0.2.5
8
18
  - **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
19
  - **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
20
  - **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`]
21
+ - **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. [`a2d5a0c`]
12
22
 
13
23
  ## 0.2.4
14
24
  - **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`]
package/CHANGELOG.zh.md CHANGED
@@ -4,11 +4,21 @@
4
4
 
5
5
  本文件按版本记录用户可感知的变化。方括号内为对应提交。
6
6
 
7
+ ## 0.2.7
8
+ - **修复:压缩阶段额度耗尽导致整轮失败**。压缩摘要是长会话发出的第一个请求,所以当前模型余额耗尽时最先失败的就是它 —— 而它被列在「不参与故障转移」的名单里,拒绝被原样抛出,回答还没开始整轮就结束了。现在压缩与其它死线路一样走候选列表:摘要有下一个模型来写,这一轮继续。会话标题仍然留在被请求的模型上 —— 一个没人等的标题不值得再找一个提供方。
9
+ - **修复:切换确认不再卡住摘要**。在压缩前弹出「是否切换模型」等于拿读者没在看的管道工作去打断他,而且超时后照样会切。压缩的切换不再询问;读者仍会在回合边界收到常规的切换通知。
10
+ - **有意不改:压缩不采纳切换后的模型**。摘要失败如果是因为上下文对这个模型太大,那与模型是否适合这段对话无关。采纳留给随后的对话请求,那里才有正确依据。
11
+ - 离线断言 168 项;三条负向对照分别证明每一处新排除条件正是对应断言通过的原因。 [`c5590ef`]
12
+
13
+ ## 0.2.6
14
+ - **修复:同一模型的其它渠道被去重挡在候选之外**。这是 0.2.5 修好分类之后暴露出来的真问题:WorkBuddy 撞上额度墙时,**buddy 与 trae 这两个同模型的渠道是健康的**,但「每个模型家族只保留一个渠道」的规则让它们根本不在候选里,于是整轮以额度错误结束 —— 而可用线路就在旁边。现每个家族保留最多 **2 个渠道**(排名靠前的一个 + 一个经证实用可用的备份);备份若正在冷却则不保留,因为那等于再撞一次同一堵墙。切换通知本就按模型 id 去重,所以落到同模型的第二个渠道不会被播报成「又切到同一模型」。
15
+ - 实测:修复前候选列表里 `deepseek-v4.1-flash` 只占 1 个渠道,`buddy`(ok=1538,健康)缺失;修复后占 3 个,`buddy` 回到列表中。[`fed9508`]
16
+
7
17
  ## 0.2.5
8
18
  - **修复:额度耗尽被误判为限流,导致不切换**。WorkBuddy 的报错是「可用账号均在限流中,最近一个将于 … 解禁」,判成 `rate-limit` 就按 60 秒冷却反复重试同一堵墙(实测一晚累计 702 次失败)。现新增「账号池耗尽」识别归入 `quota`,与「余额不足」同一处置。
9
19
  - **新增:解析报错里的解禁时间**。提供方既然写明了何时恢复,就比任何日历策略都准。`parseReleaseTime()` 支持 `2026/10/18 00:29:52`、`2026-10-18 00:29:52` 与 ISO 形式;解析到就用作精确冷却(本次实测把 60 秒变成 1.7 小时),解析不到才回落到次日策略。
10
20
  - **修复:切换后未落盘**。切换通知入队后直接 `return`,`scheduleStatsSave()` 没有被调用,所以重启后看不到这次切换发生过。现在入队时一并保存。
11
- - **改进:pin 在冷却时让位**。此前被 `model_rotator_select` 置顶的模型即使正在冷却也每轮抢在第一位,于是每轮都先撞一次墙再切换。现只在模型健康时置顶,冷却期间交给排序,冷却结束后自动回到首位。[`TBD`]
21
+ - **改进:pin 在冷却时让位**。此前被 `model_rotator_select` 置顶的模型即使正在冷却也每轮抢在第一位,于是每轮都先撞一次墙再切换。现只在模型健康时置顶,冷却期间交给排序,冷却结束后自动回到首位。[`a2d5a0c`]
12
22
 
13
23
  ## 0.2.4
14
24
  - **文档**:新增中英双语更新说明(本文件),并纳入发布清单与发布门;README 与仓库首页说明同步更新。npm 包开始随版本携带 CHANGELOG。同时修复发布门自身的一处崩溃:SHA 校验把清单注释行当成文件名读,导致整门以 ENOENT 中止、掩盖其余全部检查。[`fc14e99`]
package/index.js CHANGED
@@ -392,6 +392,16 @@ export function isNonChatModelId(provider, id) {
392
392
  /** Hard ceiling on candidates considered per turn, regardless of config. */
393
393
  const HARD_MAX_CANDIDATES = 8
394
394
 
395
+ /**
396
+ * How many channels of one model family may share the candidate walk.
397
+ *
398
+ * One per family was too few - a channel failing while its healthy siblings were
399
+ * unreachable is exactly the report this constant answers - and all of them is
400
+ * too many, because five channels of one model would spend the entire budget on
401
+ * a single model. Two keeps the representative and one proven-healthy backup.
402
+ */
403
+ const FAMILY_CHANNELS_MAX = 2
404
+
395
405
  /**
396
406
  * Hard ceiling on same-model retries per candidate, regardless of config.
397
407
  *
@@ -956,19 +966,41 @@ export function apply(ctx, config = {}, runtime = {}) {
956
966
  // ---- automatic calls ---------------------------------------------------------
957
967
 
958
968
  /**
959
- * Purposes that are not the conversation.
969
+ * Purposes that are *not* worth a second provider.
970
+ *
971
+ * A session title rides the same llm/stream seam as a user turn but is not a
972
+ * user turn: nobody is waiting on it, and failing it over asks a second
973
+ * provider to do a caption the first one refused. A refusal there is also
974
+ * frequently a policy answer - "this lane owns titles" - which another model
975
+ * would happily ignore. So a title stays on the model that was asked.
976
+ *
977
+ * Compaction is deliberately *not* in this set, even though it is plumbing
978
+ * too. It is plumbing the turn cannot proceed without: when the current
979
+ * model's quota is exhausted, the summary call is the first thing to fail,
980
+ * and passing that failure through ends the whole turn before a single token
981
+ * of the user's answer exists. A compaction that cannot run is therefore a
982
+ * dead route, exactly like a failed conversation attempt, and it walks the
983
+ * same candidate list - see AUTOMATIC_SWITCH_PURPOSES for what it skips.
960
984
  *
961
- * A compaction summary and a session title ride the same llm/stream seam as a
962
- * user turn but are *not* a user turn: nothing about them is answered to a
963
- * human, and failing one over asks a second provider to do plumbing work the
964
- * first provider refused. That refusal is also frequently a policy answer -
965
- * "this lane owns compression" - which another model would happily ignore.
966
985
  * The names come from the host itself (dsh-compaction-basic passes
967
986
  * purpose: "compaction", dsh-session-title-llm passes "session-title"), and an
968
987
  * unnamed call stays in scope, so the ordinary turns - which carry no purpose
969
988
  * at all - keep failing over exactly as before.
970
989
  */
971
- const AUTOMATIC_PURPOSES = new Set(['compaction', 'session-title'])
990
+ const AUTOMATIC_PURPOSES = new Set(['session-title'])
991
+
992
+ /**
993
+ * Purposes that fail over without asking.
994
+ *
995
+ * The switch question exists to keep the reader in charge of *their*
996
+ * conversation: a model they did not pick answering their words is worth a
997
+ * confirmation. A compaction summary is not their words, and the question
998
+ * would land while the turn is still being assembled - a 20 second stall on
999
+ * plumbing nobody is reading, which on timeout switches anyway. The walk
1000
+ * therefore proceeds unconfirmed and the reader learns about it from the
1001
+ * ordinary switch notice once the turn boundary arrives.
1002
+ */
1003
+ const UNCONFIRMED_PURPOSES = new Set(['compaction'])
972
1004
 
973
1005
  // ---- configuration ----------------------------------------------------------
974
1006
 
@@ -1509,19 +1541,35 @@ export function apply(ctx, config = {}, runtime = {}) {
1509
1541
  }
1510
1542
  ranked.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
1511
1543
 
1512
- // One candidate per model *family*, not one per channel. The same model on
1513
- // five channels shares its fate - one quota wall, one dead endpoint - so
1514
- // keeping all five in the walk spends the whole candidate budget retrying
1515
- // what is effectively one model, and the switch notice then reads as
1516
- // "switched to the same model again". The best-scoring channel of each
1517
- // family survives; its siblings wait for another turn (no cooldown is
1518
- // written for them, so a later walk can still pick them).
1519
- const familySeen = new Set()
1544
+ // A model family contributes more than one channel - but not all of them.
1545
+ //
1546
+ // The same model on five channels used to occupy five candidate slots, which
1547
+ // spent the whole budget retrying what is effectively one model. Collapsing it
1548
+ // to exactly one channel fixed that and introduced a worse failure: a channel is
1549
+ // not the whole family. WorkBuddy met its quota wall while buddy and trae, the
1550
+ // same model on other channels, were healthy and serving - and the walk could
1551
+ // not reach them, because the family had already been represented by the channel
1552
+ // that failed. The turn ended on the wall with a working route sitting one slot
1553
+ // away.
1554
+ //
1555
+ // So a family keeps up to FAMILY_CHANNELS_MAX channels, ranked, and a sibling is
1556
+ // only kept while it is actually usable. Cooling siblings are dropped: they would
1557
+ // be dispatched into the same wall the representative just hit, and the walk
1558
+ // already knows better. The switch-notice dedup keys on the model id, so arriving
1559
+ // at a second channel of the same model does not announce "switched to the same
1560
+ // model again" - the reason that motivated one-per-family in the first place is
1561
+ // handled where it belongs.
1562
+ const familyCount = new Map()
1520
1563
  const byFamily = []
1521
1564
  for (const row of ranked) {
1522
1565
  const family = String(row.pair.model).toLowerCase()
1523
- if (familySeen.has(family)) continue
1524
- familySeen.add(family)
1566
+ const used = familyCount.get(family) ?? 0
1567
+ if (used >= FAMILY_CHANNELS_MAX) continue
1568
+ const stat = statFor(row.key)
1569
+ // The family's first channel is always kept - it is the ranked winner and the
1570
+ // representation of that model. Only the siblings have to prove they are usable.
1571
+ if (used > 0 && stat.cooldownUntil > nowMs) continue
1572
+ familyCount.set(family, used + 1)
1525
1573
  byFamily.push(row)
1526
1574
  }
1527
1575
  const ordered = [current, ...byFamily.map(row => row.pair)]
@@ -1872,7 +1920,14 @@ export function apply(ctx, config = {}, runtime = {}) {
1872
1920
  // dispatch never reached a provider, so an answer here does not mean the
1873
1921
  // user's model is unreachable, and adopting would change their model
1874
1922
  // behind a fault rather than behind a failure.
1875
- if (index > 0 && flowed && cfg.adoptSwitchedModel && !hostRefusalSeen) adoptServedModel(pair)
1923
+ // Plumbing purposes are excluded from adoption on purpose: a compaction
1924
+ // that fails because the *context* is too large for this model says
1925
+ // nothing about the model's fitness for the conversation, and adopting
1926
+ // there would change the reader's model behind a size limit rather than
1927
+ // behind a dead route. The conversation attempt that follows makes the
1928
+ // same decision with the right evidence.
1929
+ const adoptHere = cfg.adoptSwitchedModel && !hostRefusalSeen && !UNCONFIRMED_PURPOSES.has(String(options.purpose ?? ''))
1930
+ if (index > 0 && flowed && adoptHere) adoptServedModel(pair)
1876
1931
  // Tell the reader which model answered instead of theirs. Recorded here
1877
1932
  // and delivered at the turn boundary (see the notice listener), because
1878
1933
  // this is the moment the walk actually left the model the user picked.
@@ -2015,8 +2070,11 @@ export function apply(ctx, config = {}, runtime = {}) {
2015
2070
 
2016
2071
  // Consent gate. Asked once per turn: approving the first switch covers
2017
2072
  // the rest of the walk, so a cascade of dead models does not become a
2018
- // cascade of identical questions.
2019
- if (!isLast && cfg.confirmSwitch && switchVerdict === '') {
2073
+ // cascade of identical questions. Plumbing purposes skip it: nobody is
2074
+ // reading the summary, and the question would park the turn on a modal
2075
+ // that only times out (see UNCONFIRMED_PURPOSES).
2076
+ const asksBeforeSwitching = cfg.confirmSwitch && !UNCONFIRMED_PURPOSES.has(String(options.purpose ?? ''))
2077
+ if (!isLast && asksBeforeSwitching && switchVerdict === '') {
2020
2078
  switchVerdict = await confirmSwitch(options, cfg, key, candidates[index + 1], failedBecause)
2021
2079
  if (switchVerdict === 'deny') {
2022
2080
  yield {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-rotator",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",