Skip to content

Commit 4bb8556

Browse files
committed
0.5.37 - 🧪 Lock in #120 fix (HALF_OPEN regression) + score split
Bundle the v0.5.36 router priority fix with two regression tests and a scoreCandidates refactor that prevents #120 from silently regressing and removes the priority-mixed-into-score footgun for future code. - scoreCandidates: score is now pure latency+uptime (0.5/0.5 weights, normalized [0,1]); priorityBonus kept as a separate field for back- compat dashboards. No more priorityWeight confusing the tiebreaker. - getRoutingCandidates: unchanged (already priority-first from 0.5.36). - DEFAULT_ROUTER_SETTINGS.scoring.priorityWeight preserved for back- compat but ignored at runtime; will be removed in a future major. - Tests: +2 regression tests for issue #120 (HALF_OPEN scenario from the user screenshot + score tiebreaker determinism). 540 → 542 passing.
1 parent f0dd3d8 commit 4bb8556

5 files changed

Lines changed: 144 additions & 5 deletions

File tree

changelog/v0.5.37.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Changelog v0.5.37 - 2026-06-20
2+
3+
### Changed
4+
- **Router `score` is now a pure latency+uptime composite (issue #120 hardening).** Previously the routing score was `0.4*latency + 0.4*uptime + 0.2*priorityBonus` — mixing explicit priority into the score could mislead tiebreakers and dashboards, because the routing comparator in `getRoutingCandidates` already enforces priority authoritatively. `scoreCandidates()` now exposes `score = 0.5*latency + 0.5*uptime` (pure model quality, normalized to `[0, 1]`), and `priorityBonus` is kept as a separate field for back-compat dashboards and legacy UIs. The remaining `latencyWeight` / `uptimeWeight` were rebalanced to `0.5 / 0.5` so the score stays in `[0, 1]` after priority is removed. Applies uniformly to CLI, Web Dashboard, and Desktop surfaces because the change lives in shared core (`src/core/router-daemon.js` + `src/core/config.js`).
5+
6+
### Fixed
7+
- **HALF_OPEN recovery is now respected by the priority-first comparator (issue #120 regression test).** The v0.5.36 fix changed `getRoutingCandidates` to sort by explicit priority before circuit state, but had no test for the exact scenario from the issue screenshot: a high-priority model sitting in `HALF_OPEN` recovery vs. a lower-priority `CLOSED` model. Added a regression test that pins `runtime.circuit.get(key).state = 'HALF_OPEN'` for priority `#1` while priority `#5` stays `CLOSED`, then verifies both `/stats.routingOrder[0]` and the actual chat-completions response target the HALF_OPEN priority-#1 model — locking in the fix so it can't silently regress.
8+
- **Score tiebreaker is deterministic when two models share the same priority (issue #120 regression test).** Added a second regression test that puts two models at the same explicit priority (rare but reachable via direct API or auto-heal) with deliberately asymmetric probe data — fast groq (80 ms) vs. slow nvidia (2000 ms) — and verifies that the higher-score model wins `routingOrder[0]`. This locks in the new pure-latency+uptime score as the deterministic tiebreaker for same-priority same-state candidates, preventing future regressions where Map iteration order or stale priority data could leak back into routing.
9+
10+
### Docs
11+
- **`DEFAULT_ROUTER_SETTINGS.scoring.priorityWeight` marked as preserved-for-back-compat.** The field still round-trips through `normalizeRouterScoring()` so user configs that customize it are not silently dropped on next save, but it is now ignored by the runtime `scoreCandidates()`. Will be removed in a future major bump. Comment block in `src/core/config.js` documents the rationale.
12+
13+
### Tests
14+
- **+2 new tests for issue #120** (`test/test.js`, `router daemon integration hardening` suite): `keeps a higher-priority HALF_OPEN model above a lower-priority CLOSED one` and `breaks score ties deterministically by latency/uptime, not priority`. Test count moves from 540 → 542, all passing.

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.36",
3+
"version": "0.5.37",
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/config.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,15 @@ export const DEFAULT_ROUTER_SETTINGS = Object.freeze({
167167
requestTimeoutMs: 15000,
168168
}),
169169
scoring: Object.freeze({
170-
latencyWeight: 0.4,
171-
uptimeWeight: 0.4,
170+
latencyWeight: 0.5,
171+
uptimeWeight: 0.5,
172+
// 📖 priorityWeight is preserved for back-compat with user configs that
173+
// 📖 set a custom value, but is no longer mixed into the routing score
174+
// 📖 (issue #120 fix, v0.5.37). Priority is now enforced authoritatively
175+
// 📖 by the comparator in getRoutingCandidates; folding it into score
176+
// 📖 could only mislead tiebreakers and dashboards. The remaining
177+
// 📖 latency+uptime weights are rebalanced to 0.5/0.5 so the score stays
178+
// 📖 in [0, 1] after priority is removed (previously 0.4/0.4/0.2).
172179
priorityWeight: 0.2,
173180
}),
174181
logLevel: 'info',
@@ -377,6 +384,10 @@ function normalizeRouterScoring(scoring) {
377384
const numeric = Number(value)
378385
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback
379386
}
387+
// 📖 priorityWeight is normalized but currently IGNORED by the runtime
388+
// 📖 scoreCandidates() (issue #120, v0.5.37). Kept here so existing user
389+
// 📖 configs that customize this field round-trip cleanly and don't lose
390+
// 📖 their setting on next save. Will be removed in a future major bump.
380391
return {
381392
latencyWeight: numberOrDefault(safeScoring.latencyWeight, DEFAULT_ROUTER_SETTINGS.scoring.latencyWeight),
382393
uptimeWeight: numberOrDefault(safeScoring.uptimeWeight, DEFAULT_ROUTER_SETTINGS.scoring.uptimeWeight),

src/core/router-daemon.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,15 +1149,27 @@ class RouterRuntime {
11491149
const hasData = stats.total > 0
11501150
const latencyScore = stats.p95 === null ? 0.5 : Math.max(0, 1 - (stats.p95 / maxP95))
11511151
const uptimeScore = stats.uptime === null ? 0.5 : stats.uptime
1152+
// 📖 priorityBonus - kept as a separate field for dashboards/legacy UIs
1153+
// 📖 that previously rendered a single composite "score". Priority is
1154+
// 📖 NOT folded into `score` anymore (issue #120): the routing comparator
1155+
// 📖 in getRoutingCandidates sorts by explicit priority authoritatively,
1156+
// 📖 so mixing priority into the score only confused tiebreakers.
11521157
const priorityBonus = 1 - ((entry.priority - 1) / setSize)
1158+
// 📖 score - pure latency+uptime composite. Used only as the FINAL
1159+
// 📖 tiebreaker between candidates that share the same priority AND
1160+
// 📖 same circuit state (see getRoutingCandidates). A model with no
1161+
// 📖 probe data yet scores neutral (0.5) - we deliberately do NOT use
1162+
// 📖 priorityBonus as a cold-start fallback, because that would re-
1163+
// 📖 introduce the priority-in-score confusion this refactor removes.
11531164
const score = hasData
1154-
? (weights.latencyWeight * latencyScore) + (weights.uptimeWeight * uptimeScore) + (weights.priorityWeight * priorityBonus)
1155-
: priorityBonus
1165+
? (weights.latencyWeight * latencyScore) + (weights.uptimeWeight * uptimeScore)
1166+
: 0.5
11561167
const state = this.updateCircuitForCooldown(key) || {}
11571168
return {
11581169
...entry,
11591170
key,
11601171
score,
1172+
priorityBonus,
11611173
stats,
11621174
circuit: state,
11631175
catalog: this.modelCatalog.get(key) || null,

test/test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3119,6 +3119,108 @@ describe('router daemon integration hardening', () => {
31193119
})
31203120
})
31213121

3122+
// 📖 Regression for issue #120 — HALF_OPEN recovery must NOT be skipped.
3123+
// 📖 The screenshot from the user showed a HALF_OPEN priority-#1 model
3124+
// 📖 being skipped for a CLOSED priority-#20 model. The priority-first
3125+
// 📖 comparator in getRoutingCandidates now guarantees explicit priority
3126+
// 📖 wins over circuit state, so a high-priority model mid-recovery is
3127+
// 📖 still tried before any lower-priority CLOSED fallback.
3128+
it('keeps a higher-priority HALF_OPEN model above a lower-priority CLOSED one (issue #120)', async () => {
3129+
await withMockProvider(() => ({
3130+
body: { id: 'chatcmpl-halfopen', choices: [{ message: { role: 'assistant', content: 'from-halfopen' } }] },
3131+
}), async (groqProvider) => {
3132+
await withMockProvider(() => ({
3133+
body: { id: 'chatcmpl-closed', choices: [{ message: { role: 'assistant', content: 'from-closed' } }] },
3134+
}), async (nvidiaProvider) => {
3135+
await withSourceUrls({ groq: groqProvider.url, nvidia: nvidiaProvider.url }, async () => {
3136+
const config = buildRouterTestConfig([
3137+
{ provider: 'groq', model: ROUTER_TEST_MODELS.groqFast, priority: 1 },
3138+
{ provider: 'nvidia', model: ROUTER_TEST_MODELS.nvidiaFast, priority: 5 },
3139+
])
3140+
await withRouterTestServer(config, async ({ baseUrl, runtime }) => {
3141+
// 📖 Force groq (priority #1) into HALF_OPEN recovery. nvidia
3142+
// 📖 (priority #5) stays CLOSED. The OLD code would have sorted
3143+
// 📖 HALF_OPEN AFTER CLOSED regardless of priority; the NEW
3144+
// 📖 comparator must keep groq at the top.
3145+
const groqKey = `groq/${ROUTER_TEST_MODELS.groqFast}`
3146+
const groqCircuit = runtime.circuit.get(groqKey)
3147+
assert.ok(groqCircuit, 'groq circuit entry must exist')
3148+
groqCircuit.state = 'HALF_OPEN'
3149+
groqCircuit.openedAt = Date.now() - 60_000
3150+
3151+
// 📖 routingOrder surface: groq (HALF_OPEN, priority 1) MUST be
3152+
// 📖 before nvidia (CLOSED, priority 5) — issue #120's exact case.
3153+
const stats = await (await fetch(`${baseUrl}/stats`)).json()
3154+
assert.equal(stats.routingOrder[0].key, groqKey,
3155+
'priority-#1 HALF_OPEN must come before priority-#5 CLOSED')
3156+
assert.equal(stats.routingOrder[0].state, 'HALF_OPEN')
3157+
assert.equal(stats.routingOrder[1].key, `nvidia/${ROUTER_TEST_MODELS.nvidiaFast}`)
3158+
assert.equal(stats.routingOrder[1].state, 'CLOSED')
3159+
3160+
// 📖 End-to-end check: a real chat-completions request hits groq
3161+
// 📖 (the HALF_OPEN priority-#1), NOT nvidia (the CLOSED priority-#5).
3162+
const response = await postRouterChat(baseUrl)
3163+
assert.equal(response.status, 200)
3164+
assert.equal(response.headers.get('x-fcm-router-model'), groqKey)
3165+
assert.equal(groqProvider.requests.length, 1)
3166+
assert.equal(nvidiaProvider.requests.length, 0)
3167+
})
3168+
})
3169+
})
3170+
})
3171+
})
3172+
3173+
// 📖 Regression for issue #120 — score tiebreaker determinism.
3174+
// 📖 When two candidates share the same explicit priority AND the same
3175+
// 📖 circuit state, the comparator falls back to score. With the v0.5.37
3176+
// 📖 refactor, score is pure latency+uptime (no priorityWeight) so the
3177+
// 📖 tiebreaker is deterministic and reflects actual model quality.
3178+
it('breaks score ties deterministically by latency/uptime, not priority (issue #120)', async () => {
3179+
await withMockProvider(() => ({
3180+
body: { id: 'chatcmpl-winner', choices: [{ message: { role: 'assistant', content: 'high-score' } }] },
3181+
}), async (groqProvider) => {
3182+
await withMockProvider(() => ({
3183+
body: { id: 'chatcmpl-loser', choices: [{ message: { role: 'assistant', content: 'low-score' } }] },
3184+
}), async (nvidiaProvider) => {
3185+
await withSourceUrls({ groq: groqProvider.url, nvidia: nvidiaProvider.url }, async () => {
3186+
// 📖 Two models deliberately at the SAME priority (rare but
3187+
// 📖 possible via direct API or auto-heal). Tiebreaker must fall
3188+
// 📖 back to score (latency+uptime), not priority (they're equal),
3189+
// 📖 not random Map iteration order.
3190+
const config = buildRouterTestConfig([
3191+
{ provider: 'groq', model: ROUTER_TEST_MODELS.groqFast, priority: 1 },
3192+
{ provider: 'nvidia', model: ROUTER_TEST_MODELS.nvidiaFast, priority: 1 },
3193+
])
3194+
await withRouterTestServer(config, async ({ baseUrl, runtime }) => {
3195+
// 📖 Build a clear asymmetry: groq has fast probes (high score),
3196+
// 📖 nvidia has slow probes (low score). Same priority, same state.
3197+
const groqKey = `groq/${ROUTER_TEST_MODELS.groqFast}`
3198+
const nvidiaKey = `nvidia/${ROUTER_TEST_MODELS.nvidiaFast}`
3199+
for (let i = 0; i < 6; i++) runtime.recordProbeResult(groqKey, { ok: true, latencyMs: 80, code: 200 })
3200+
for (let i = 0; i < 6; i++) runtime.recordProbeResult(nvidiaKey, { ok: true, latencyMs: 2000, code: 200 })
3201+
3202+
const stats = await (await fetch(`${baseUrl}/stats`)).json()
3203+
assert.equal(stats.routingOrder[0].key, groqKey,
3204+
'higher-score model must win same-priority same-state tiebreaker')
3205+
assert.equal(stats.routingOrder[1].key, nvidiaKey)
3206+
3207+
// 📖 priorityBonus is still exposed separately for back-compat
3208+
// 📖 dashboards, but is NOT mixed into the routing score.
3209+
const groqHealth = stats.models.find((m) => m.key === groqKey)
3210+
const nvidiaHealth = stats.models.find((m) => m.key === nvidiaKey)
3211+
assert.ok(groqHealth.score > nvidiaHealth.score,
3212+
`groq score (${groqHealth.score}) must exceed nvidia (${nvidiaHealth.score})`)
3213+
3214+
// 📖 End-to-end: the chat-completions request hits the high-
3215+
// 📖 score groq model.
3216+
const response = await postRouterChat(baseUrl)
3217+
assert.equal(response.headers.get('x-fcm-router-model'), groqKey)
3218+
})
3219+
})
3220+
})
3221+
})
3222+
})
3223+
31223224
it('returns precise quota metadata when every routed model is exhausted', async () => {
31233225
await withMockProvider(() => ({
31243226
status: 429,

0 commit comments

Comments
 (0)