dsh-model-rotator 0.2.4 → 0.2.6
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 +10 -0
- package/CHANGELOG.zh.md +10 -0
- package/index.js +107 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
User-visible changes, newest first. Commit hashes in brackets.
|
|
6
6
|
|
|
7
|
+
## 0.2.6
|
|
8
|
+
- **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".
|
|
9
|
+
- 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. [`TBD`]
|
|
10
|
+
|
|
11
|
+
## 0.2.5
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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`]
|
|
16
|
+
|
|
7
17
|
## 0.2.4
|
|
8
18
|
- **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`]
|
|
9
19
|
|
package/CHANGELOG.zh.md
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
本文件按版本记录用户可感知的变化。方括号内为对应提交。
|
|
6
6
|
|
|
7
|
+
## 0.2.6
|
|
8
|
+
- **修复:同一模型的其它渠道被去重挡在候选之外**。这是 0.2.5 修好分类之后暴露出来的真问题:WorkBuddy 撞上额度墙时,**buddy 与 trae 这两个同模型的渠道是健康的**,但「每个模型家族只保留一个渠道」的规则让它们根本不在候选里,于是整轮以额度错误结束 —— 而可用线路就在旁边。现每个家族保留最多 **2 个渠道**(排名靠前的一个 + 一个经证实用可用的备份);备份若正在冷却则不保留,因为那等于再撞一次同一堵墙。切换通知本就按模型 id 去重,所以落到同模型的第二个渠道不会被播报成「又切到同一模型」。
|
|
9
|
+
- 实测:修复前候选列表里 `deepseek-v4.1-flash` 只占 1 个渠道,`buddy`(ok=1538,健康)缺失;修复后占 3 个,`buddy` 回到列表中。[`TBD`]
|
|
10
|
+
|
|
11
|
+
## 0.2.5
|
|
12
|
+
- **修复:额度耗尽被误判为限流,导致不切换**。WorkBuddy 的报错是「可用账号均在限流中,最近一个将于 … 解禁」,判成 `rate-limit` 就按 60 秒冷却反复重试同一堵墙(实测一晚累计 702 次失败)。现新增「账号池耗尽」识别归入 `quota`,与「余额不足」同一处置。
|
|
13
|
+
- **新增:解析报错里的解禁时间**。提供方既然写明了何时恢复,就比任何日历策略都准。`parseReleaseTime()` 支持 `2026/10/18 00:29:52`、`2026-10-18 00:29:52` 与 ISO 形式;解析到就用作精确冷却(本次实测把 60 秒变成 1.7 小时),解析不到才回落到次日策略。
|
|
14
|
+
- **修复:切换后未落盘**。切换通知入队后直接 `return`,`scheduleStatsSave()` 没有被调用,所以重启后看不到这次切换发生过。现在入队时一并保存。
|
|
15
|
+
- **改进:pin 在冷却时让位**。此前被 `model_rotator_select` 置顶的模型即使正在冷却也每轮抢在第一位,于是每轮都先撞一次墙再切换。现只在模型健康时置顶,冷却期间交给排序,冷却结束后自动回到首位。[`a2d5a0c`]
|
|
16
|
+
|
|
7
17
|
## 0.2.4
|
|
8
18
|
- **文档**:新增中英双语更新说明(本文件),并纳入发布清单与发布门;README 与仓库首页说明同步更新。npm 包开始随版本携带 CHANGELOG。同时修复发布门自身的一处崩溃:SHA 校验把清单注释行当成文件名读,导致整门以 ENOENT 中止、掩盖其余全部检查。[`fc14e99`]
|
|
9
19
|
|
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
|
*
|
|
@@ -703,6 +713,12 @@ const FAILURE_KIND_TESTS = [
|
|
|
703
713
|
// that classification would cost the daily rest a dead balance needs.
|
|
704
714
|
['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
715
|
['auth', /(invalid[ _-]*api[ _-]*key|api[ _-]*key[ _-]*(invalid|missing|expired)|unauthori[sz]ed|unauthenticated|authentication|credential|permission denied|forbidden|401|403|鉴权|密钥|授权失败|认证失败)/i],
|
|
716
|
+
// An account pool that is entirely unavailable is a quota wall wearing the
|
|
717
|
+
// word "rate limit". WorkBuddy answers "可用账号均在限流中,最近一个将于 … 解禁" —
|
|
718
|
+
// every account exhausted, with the release timestamp in the sentence. Classified
|
|
719
|
+
// as rate-limit it cooled for 60s and retried the same wall 702 times in one
|
|
720
|
+
// evening; as quota it rests until the pool actually comes back.
|
|
721
|
+
['quota', /(?:可用)?账号[^。,,;;]{0,6}(?:均|全部|都)[^。,,;;]{0,8}(?:限流|不可用|耗尽|用尽)|均在限流|均不可用|all accounts?(?:[ _-]+\w+){0,3}[ _-]+(?:exhausted|unavailable|rate[ _-]*limited)|every account[ _-]+(?:is[ _-]+)?(?:exhausted|unavailable)/i],
|
|
706
722
|
['rate-limit', /(rate[ _-]*limit|too many requests|request[ _-]*limit|throttl|429|限流|请求过于频繁|过于频繁|qps)/i],
|
|
707
723
|
['server', /(internal[ _-]*server[ _-]*error|bad[ _-]*gateway|service[ _-]*unavailable|server[ _-]*(error|overload)|overloaded|gateway[ _-]*timeout|50[0-9])/i],
|
|
708
724
|
['network', /(econnreset|econnrefused|etimedout|enotfound|eai_again|socket|network|fetch failed|timed?[ _-]?out|timeout|disconnected|connection (closed|reset|refused)|aborted)/i],
|
|
@@ -768,9 +784,54 @@ export function quotaRestUntil(nowMs, cfg) {
|
|
|
768
784
|
return Math.max(boundary.getTime(), nowMs + floor)
|
|
769
785
|
}
|
|
770
786
|
|
|
787
|
+
/**
|
|
788
|
+
* A release time the provider stated in its own words, in epoch ms, or 0.
|
|
789
|
+
*
|
|
790
|
+
* A quota message often says exactly when the pool comes back — WorkBuddy writes
|
|
791
|
+
* "最近一个将于 2026/10/18 00:29:52 解禁". That timestamp is strictly better than any
|
|
792
|
+
* policy we could invent: it is the provider telling us when to try again. Fixed
|
|
793
|
+
* midnight arithmetic ignored it and could either sleep far too long or wake into
|
|
794
|
+
* the same wall.
|
|
795
|
+
*
|
|
796
|
+
* Accepts the shapes that actually appear: `2026/10/18 00:29:52`, `2026-10-18
|
|
797
|
+
* 00:29:52`, and an ISO 8601 stamp. A date with no time is treated as that day's
|
|
798
|
+
* midnight. The value is read in the host's local zone, which is the zone these
|
|
799
|
+
* messages are written in.
|
|
800
|
+
*
|
|
801
|
+
* Returns 0 when nothing parseable is present, so the caller falls back to policy.
|
|
802
|
+
* Exported as a test seam: this is date arithmetic on prose, exactly the kind of
|
|
803
|
+
* thing that should be pinned by an offline assertion.
|
|
804
|
+
*/
|
|
805
|
+
export function parseReleaseTime(message) {
|
|
806
|
+
const text = String(message ?? '')
|
|
807
|
+
if (text === '') return 0
|
|
808
|
+
const patterns = [
|
|
809
|
+
/(\d{4})[/-](\d{1,2})[/-](\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?/,
|
|
810
|
+
/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/,
|
|
811
|
+
]
|
|
812
|
+
for (const re of patterns) {
|
|
813
|
+
const m = text.match(re)
|
|
814
|
+
if (m === null) continue
|
|
815
|
+
const [, y, mo, d, h = '0', mi = '0', sec = '0'] = m
|
|
816
|
+
const at = new Date(
|
|
817
|
+
Number(y), Number(mo) - 1, Number(d),
|
|
818
|
+
Number(h), Number(mi), Number(sec), 0,
|
|
819
|
+
)
|
|
820
|
+
const ms = at.getTime()
|
|
821
|
+
// A provider sentence about the past is a stale copy, not a release time.
|
|
822
|
+
if (Number.isFinite(ms) && ms > 0) return ms
|
|
823
|
+
}
|
|
824
|
+
return 0
|
|
825
|
+
}
|
|
826
|
+
|
|
771
827
|
/** Instant until which this failure keeps the candidate out of the ranking. */
|
|
772
|
-
function cooldownUntilFor(stat, cfg, nowMs, errorKind) {
|
|
773
|
-
if (errorKind === 'quota')
|
|
828
|
+
function cooldownUntilFor(stat, cfg, nowMs, errorKind, message) {
|
|
829
|
+
if (errorKind === 'quota') {
|
|
830
|
+
// The provider's own release timestamp outranks our calendar policy.
|
|
831
|
+
const stated = parseReleaseTime(message)
|
|
832
|
+
if (stated > nowMs) return stated
|
|
833
|
+
return quotaRestUntil(nowMs, cfg)
|
|
834
|
+
}
|
|
774
835
|
return nowMs + cooldownFor(stat, cfg)
|
|
775
836
|
}
|
|
776
837
|
|
|
@@ -1458,27 +1519,52 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1458
1519
|
}
|
|
1459
1520
|
ranked.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
1460
1521
|
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
// what is effectively one model
|
|
1465
|
-
//
|
|
1466
|
-
// family
|
|
1467
|
-
//
|
|
1468
|
-
|
|
1522
|
+
// A model family contributes more than one channel - but not all of them.
|
|
1523
|
+
//
|
|
1524
|
+
// The same model on five channels used to occupy five candidate slots, which
|
|
1525
|
+
// spent the whole budget retrying what is effectively one model. Collapsing it
|
|
1526
|
+
// to exactly one channel fixed that and introduced a worse failure: a channel is
|
|
1527
|
+
// not the whole family. WorkBuddy met its quota wall while buddy and trae, the
|
|
1528
|
+
// same model on other channels, were healthy and serving - and the walk could
|
|
1529
|
+
// not reach them, because the family had already been represented by the channel
|
|
1530
|
+
// that failed. The turn ended on the wall with a working route sitting one slot
|
|
1531
|
+
// away.
|
|
1532
|
+
//
|
|
1533
|
+
// So a family keeps up to FAMILY_CHANNELS_MAX channels, ranked, and a sibling is
|
|
1534
|
+
// only kept while it is actually usable. Cooling siblings are dropped: they would
|
|
1535
|
+
// be dispatched into the same wall the representative just hit, and the walk
|
|
1536
|
+
// already knows better. The switch-notice dedup keys on the model id, so arriving
|
|
1537
|
+
// at a second channel of the same model does not announce "switched to the same
|
|
1538
|
+
// model again" - the reason that motivated one-per-family in the first place is
|
|
1539
|
+
// handled where it belongs.
|
|
1540
|
+
const familyCount = new Map()
|
|
1469
1541
|
const byFamily = []
|
|
1470
1542
|
for (const row of ranked) {
|
|
1471
1543
|
const family = String(row.pair.model).toLowerCase()
|
|
1472
|
-
|
|
1473
|
-
|
|
1544
|
+
const used = familyCount.get(family) ?? 0
|
|
1545
|
+
if (used >= FAMILY_CHANNELS_MAX) continue
|
|
1546
|
+
const stat = statFor(row.key)
|
|
1547
|
+
// The family's first channel is always kept - it is the ranked winner and the
|
|
1548
|
+
// representation of that model. Only the siblings have to prove they are usable.
|
|
1549
|
+
if (used > 0 && stat.cooldownUntil > nowMs) continue
|
|
1550
|
+
familyCount.set(family, used + 1)
|
|
1474
1551
|
byFamily.push(row)
|
|
1475
1552
|
}
|
|
1476
1553
|
const ordered = [current, ...byFamily.map(row => row.pair)]
|
|
1477
1554
|
|
|
1478
1555
|
// A pin short-circuits the ranking: the user asked for this head explicitly.
|
|
1556
|
+
//
|
|
1557
|
+
// Unless it is cooling down. A pin is a preference about which healthy model to
|
|
1558
|
+
// try first, not a promise to walk into the same wall every turn: a pinned model
|
|
1559
|
+
// that just failed would be dispatched first on every subsequent turn, fail
|
|
1560
|
+
// again, and make the reader watch the same switch over and over. While it cools
|
|
1561
|
+
// the ranking decides, and the model returns to the head by itself once the
|
|
1562
|
+
// cooldown clears - the pin is not forgotten, only deferred.
|
|
1479
1563
|
if (pinned !== '' && pinned !== current.provider + '/' + current.model) {
|
|
1480
1564
|
const at = ordered.findIndex(pair => pair.provider + '/' + pair.model === pinned)
|
|
1481
|
-
|
|
1565
|
+
const pinnedStat = at > 0 ? statFor(pinned) : null
|
|
1566
|
+
const pinnedCooling = pinnedStat !== null && pinnedStat.cooldownUntil > nowMs
|
|
1567
|
+
if (at > 0 && !pinnedCooling) ordered.unshift(ordered.splice(at, 1)[0])
|
|
1482
1568
|
}
|
|
1483
1569
|
|
|
1484
1570
|
return ordered.slice(0, Math.min(cfg.maxCandidates, HARD_MAX_CANDIDATES))
|
|
@@ -1836,6 +1922,11 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1836
1922
|
to: { provider: pair.provider, model: pair.model, displayName: pair.displayName ?? '' },
|
|
1837
1923
|
reason: classifyFailureKind(String(lastFailure ?? '')),
|
|
1838
1924
|
})
|
|
1925
|
+
// The switch itself is the event worth persisting. Without this the
|
|
1926
|
+
// counters above were written but the *decision* was not, so a restart
|
|
1927
|
+
// showed the old model still in use and the switch looked like it had
|
|
1928
|
+
// never happened.
|
|
1929
|
+
scheduleStatsSave()
|
|
1839
1930
|
}
|
|
1840
1931
|
}
|
|
1841
1932
|
return
|
|
@@ -1880,7 +1971,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1880
1971
|
// buries the provider's words under ours.
|
|
1881
1972
|
if (!hostRefusal || trustedFailure) {
|
|
1882
1973
|
stat.consecutiveFailures += 1
|
|
1883
|
-
stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind)
|
|
1974
|
+
stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind, failedBecause)
|
|
1884
1975
|
}
|
|
1885
1976
|
// The error chunk is already in the consumer's hands, so the walk ends
|
|
1886
1977
|
// here either way - and it must, because a fault of ours would repeat
|
|
@@ -1924,7 +2015,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1924
2015
|
// route stays fully available to the next turn.
|
|
1925
2016
|
if (!hostRefusal || trustedFailure) {
|
|
1926
2017
|
stat.consecutiveFailures += 1
|
|
1927
|
-
stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind)
|
|
2018
|
+
stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind, failedBecause)
|
|
1928
2019
|
}
|
|
1929
2020
|
|
|
1930
2021
|
// A fault of ours ends the walk. Every further candidate would hit the
|
|
@@ -2625,7 +2716,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2625
2716
|
const failedKind = classifyFailureKind(failure)
|
|
2626
2717
|
stat.failures += 1
|
|
2627
2718
|
stat.consecutiveFailures += 1
|
|
2628
|
-
stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind)
|
|
2719
|
+
stat.cooldownUntil = cooldownUntilFor(stat, cfg, Date.now(), failedKind, failedBecause)
|
|
2629
2720
|
stat.lastError = failure
|
|
2630
2721
|
stat.lastErrorKind = failedKind
|
|
2631
2722
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-rotator",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
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",
|