Skip to content

Commit f0dd3d8

Browse files
committed
0.5.36 - 🐛 Fix router priority ordering (HALF_OPEN skip)
Bundles router priority fix + probe-loop hardening, provider-key-test re-exports completing #121, and @MoriDanWork contributor credit. - Router: sort by explicit priority BEFORE circuit state so a high-priority HALF_OPEN model is no longer skipped for a lower-priority CLOSED one. - Probe loop: watchdog cleanup, lastProbeAt tracking, try/catch around cycles. - key-handler: re-export shared provider-key-test helpers (finishes #121). - Docs: credit @MoriDanWork in README + AGENTS.md.
1 parent 488bf16 commit f0dd3d8

6 files changed

Lines changed: 78 additions & 20 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ When new PRs are merged, add the contributor's GitHub handle to the footer in `b
8888
- @whit3rabbit
8989
- @PhucTruong-ctrl
9090
- @stgreenb
91+
- @MoriDanWork
9192

9293
## Testing the TUI with tmux
9394

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ Telemetry is enabled by default and can be disabled with any of the following:
676676
<td align="center" width="120"><a href="https://github.com/chindris-mihai-alexandru"><img src="https://avatars.githubusercontent.com/u/12643176?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="chindris-mihai-alexandru"></a></td>
677677
<td align="center" width="120"><a href="https://github.com/serajbaltu"><img src="https://avatars.githubusercontent.com/u/90699173?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="serajbaltu"></a></td>
678678
<td align="center" width="120"><a href="https://github.com/stgreenb"><img src="https://avatars.githubusercontent.com/u/18483964?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="stgreenb"></a></td>
679+
<td align="center" width="120"><a href="https://github.com/MoriDanWork"><img src="https://avatars.githubusercontent.com/u/55363096?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="MoriDanWork"></a></td>
679680
</tr>
680681
<tr>
681682
<td align="center"><a href="https://github.com/vava-nessa"><sub><b>vava-nessa</b></sub></a></td>
@@ -686,6 +687,7 @@ Telemetry is enabled by default and can be disabled with any of the following:
686687
<td align="center"><a href="https://github.com/chindris-mihai-alexandru"><sub><b>chindris-mihai-alexandru</b></sub></a></td>
687688
<td align="center"><a href="https://github.com/serajbaltu"><sub><b>serajbaltu</b></sub></a></td>
688689
<td align="center"><a href="https://github.com/stgreenb"><sub><b>stgreenb</b></sub></a></td>
690+
<td align="center"><a href="https://github.com/MoriDanWork"><sub><b>MoriDanWork</b></sub></a></td>
689691
</tr>
690692
</table>
691693

changelog/v0.5.36.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Changelog v0.5.36 - 2026-06-20
2+
3+
### Fixed
4+
- **Router priority ordering now respects explicit priority over circuit state.** Previously, routing candidates were ordered strictly by circuit state (`CLOSED` candidates always before `HALF_OPEN`), then by priority and health score. This meant a high-priority model sitting in `HALF_OPEN` recovery could be skipped in favor of a lower-priority `CLOSED` model — defeating the whole point of explicit priority levels. The comparator now sorts by **explicit priority first**, then by circuit state (`CLOSED` before `HALF_OPEN`), then by health score. Higher-priority models are never again jumped over just because they are mid-recovery.
5+
6+
### Changed
7+
- **Probe loop hardening.** `scheduleProbeLoop()` now clears a stale `probeWatchdog` timer alongside the main probe timer, tracks `lastProbeAt` timestamps after every successful cycle, and wraps the scheduling body in a `try/catch` so a single failed probe cycle can no longer destabilize the background routing daemon. Results in more reliable health-check cadences across the CLI, web, and Tauri surfaces (shared core).
8+
9+
### Added
10+
- **Public re-exports of the provider-key-test helpers** from `key-handler.js` (`buildProviderModelsUrl`, `parseProviderModelIds`, `listProviderTestModels`, `classifyProviderTestOutcome`, `buildProviderTestDetail`). Completes the shared-module refactor introduced in #121 (provider key testing moved to `src/core/provider-key-tester.js`) so the TUI key-handler exposes the same surface other modules already consume.
11+
12+
### Docs
13+
- **Credited @MoriDanWork** in the README contributors table and the AGENTS.md contributor list for PR #121 (move provider key testing to a shared module).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "free-coding-models",
3-
"version": "0.5.35",
3+
"version": "0.5.36",
44
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
55
"keywords": [
66
"nvidia",

src/core/router-daemon.js

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,12 +1193,24 @@ class RouterRuntime {
11931193
if (!this.getApiKeyForProvider(candidate.provider)) return false
11941194
return candidate.circuit?.state === 'CLOSED' || candidate.circuit?.state === 'HALF_OPEN'
11951195
})
1196-
const closed = usable.filter((candidate) => candidate.circuit.state === 'CLOSED')
1197-
const halfOpen = usable.filter((candidate) => candidate.circuit.state === 'HALF_OPEN')
1198-
// 📖 Priority ascending (1 before 2); within the same priority, healthier
1199-
// 📖 score wins so cold-start ties resolve deterministically.
1200-
const byPriorityThenHealth = (a, b) => a.priority - b.priority || b.score - a.score
1201-
return [...closed.sort(byPriorityThenHealth), ...halfOpen.sort(byPriorityThenHealth)]
1196+
// 📖 New ordering: prioritize by explicit priority first, then by circuit state
1197+
// 📖 (CLOSED before HALF_OPEN), and finally by health score (higher is better).
1198+
// 📖 This ensures a higher‑priority model is never skipped just because it is
1199+
// 📖 in HALF_OPEN while a lower‑priority CLOSED model is available.
1200+
const stateOrder = { CLOSED: 0, HALF_OPEN: 1 }
1201+
const comparator = (a, b) => {
1202+
if (a.priority !== b.priority) return a.priority - b.priority
1203+
const aState = a.circuit?.state || 'UNKNOWN'
1204+
const bState = b.circuit?.state || 'UNKNOWN'
1205+
if (aState !== bState) {
1206+
const aRank = stateOrder[aState] ?? 2
1207+
const bRank = stateOrder[bState] ?? 2
1208+
return aRank - bRank
1209+
}
1210+
// higher score first
1211+
return b.score - a.score
1212+
}
1213+
return usable.sort(comparator)
12021214
}
12031215

12041216
// 📖 getRoutingOrder - slim projection of getRoutingCandidates for the /stats
@@ -1738,27 +1750,48 @@ class RouterRuntime {
17381750
}
17391751

17401752
scheduleProbeLoop() {
1753+
// Clear any existing timers
17411754
if (this.probeTimer) clearInterval(this.probeTimer)
1755+
if (this.probeWatchdog) clearInterval(this.probeWatchdog)
17421756
for (const timeout of this.probeTimeouts) clearTimeout(timeout)
17431757
this.probeTimeouts.clear()
1758+
17441759
const router = this.routerConfig()
17451760
const interval = router.probeIntervals[router.probeMode] || DEFAULT_ROUTER_SETTINGS.probeIntervals.balanced
1761+
// Track last successful probe cycle timestamp
1762+
this.lastProbeAt = Date.now()
1763+
17461764
this.probeTimer = setInterval(() => {
1747-
const set = this.getSet()
1748-
if (!set || this.shuttingDown) return
1749-
const candidates = this.scoreCandidates(set)
1750-
.filter((candidate) => candidate.catalog?.routeable && !candidate.circuit?.stale)
1751-
const stagger = candidates.length > 0 ? Math.max(250, Math.floor(interval / candidates.length)) : interval
1752-
candidates.forEach((candidate, index) => {
1753-
const timeout = setTimeout(() => {
1754-
this.probeTimeouts.delete(timeout)
1755-
void this.probeCandidate(candidate, { eco: router.probeMode === 'eco' })
1756-
}, index * stagger)
1757-
timeout.unref?.()
1758-
this.probeTimeouts.add(timeout)
1759-
})
1765+
try {
1766+
const set = this.getSet()
1767+
if (!set || this.shuttingDown) return
1768+
const candidates = this.scoreCandidates(set)
1769+
.filter((candidate) => candidate.catalog?.routeable && !candidate.circuit?.stale)
1770+
const stagger = candidates.length > 0 ? Math.max(250, Math.floor(interval / candidates.length)) : interval
1771+
candidates.forEach((candidate, index) => {
1772+
const timeout = setTimeout(() => {
1773+
this.probeTimeouts.delete(timeout)
1774+
void this.probeCandidate(candidate, { eco: router.probeMode === 'eco' })
1775+
}, index * stagger)
1776+
timeout.unref?.()
1777+
this.probeTimeouts.add(timeout)
1778+
})
1779+
// Update timestamp after scheduling probes
1780+
this.lastProbeAt = Date.now()
1781+
} catch (err) {
1782+
this.logger.error('[ProbeLoop] error', { error: err })
1783+
}
17601784
}, interval)
17611785
this.probeTimer.unref?.()
1786+
1787+
// Watchdog: if no successful cycle for 3x interval, restart loop
1788+
this.probeWatchdog = setInterval(() => {
1789+
if (this.lastProbeAt && Date.now() - this.lastProbeAt > interval * 3) {
1790+
this.logger.warn('[ProbeLoop] stall detected, restarting probe loop')
1791+
this.scheduleProbeLoop()
1792+
}
1793+
}, interval)
1794+
this.probeWatchdog.unref?.()
17621795
}
17631796

17641797
async routeRequest({ req, res, body, setName, requestId }) {
@@ -2357,6 +2390,7 @@ class RouterRuntime {
23572390
this.markSetCustomized()
23582391
this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: name, action: 'add', model: newEntry })
23592392
sendJson(res, 201, { set: normalized.sets[name], router: normalized }, { 'x-request-id': requestId })
2393+
void this.runProbeBurst()
23602394
return
23612395
}
23622396

src/tui/key-handler.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3484,3 +3484,11 @@ export function createMouseEventHandler(ctx) {
34843484
// 📖 Clicks outside any recognized zone are silently ignored.
34853485
}
34863486
}
3487+
3488+
export {
3489+
buildProviderModelsUrl,
3490+
parseProviderModelIds,
3491+
listProviderTestModels,
3492+
classifyProviderTestOutcome,
3493+
buildProviderTestDetail,
3494+
};

0 commit comments

Comments
 (0)