free-coding-models 0.5.35 → 0.5.37

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/README.md CHANGED
@@ -676,6 +676,7 @@ Telemetry is enabled by default and can be disabled with any of the following:
676
676
  <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>
677
677
  <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>
678
678
  <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>
679
680
  </tr>
680
681
  <tr>
681
682
  <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:
686
687
  <td align="center"><a href="https://github.com/chindris-mihai-alexandru"><sub><b>chindris-mihai-alexandru</b></sub></a></td>
687
688
  <td align="center"><a href="https://github.com/serajbaltu"><sub><b>serajbaltu</b></sub></a></td>
688
689
  <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>
689
691
  </tr>
690
692
  </table>
691
693
 
@@ -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).
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.35",
3
+ "version": "0.5.37",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -167,8 +167,15 @@ export const DEFAULT_ROUTER_SETTINGS = Object.freeze({
167
167
  requestTimeoutMs: 15000,
168
168
  }),
169
169
  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).
172
179
  priorityWeight: 0.2,
173
180
  }),
174
181
  logLevel: 'info',
@@ -377,6 +384,10 @@ function normalizeRouterScoring(scoring) {
377
384
  const numeric = Number(value)
378
385
  return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback
379
386
  }
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.
380
391
  return {
381
392
  latencyWeight: numberOrDefault(safeScoring.latencyWeight, DEFAULT_ROUTER_SETTINGS.scoring.latencyWeight),
382
393
  uptimeWeight: numberOrDefault(safeScoring.uptimeWeight, DEFAULT_ROUTER_SETTINGS.scoring.uptimeWeight),
@@ -0,0 +1,369 @@
1
+ /**
2
+ * @file provider-key-tester.js
3
+ * @description Shared provider API key verification logic used by both TUI and Web surfaces.
4
+ *
5
+ * @details
6
+ * This module extracts the key-testing pipeline that was previously embedded in
7
+ * `src/tui/key-handler.js` so the Web Dashboard's `/api/key/:provider/test` endpoint
8
+ * can use the exact same strategy without duplicating code.
9
+ *
10
+ * Pipeline overview:
11
+ * 1. Fast path — parallel auth-only probes (3×8 s) to `/v1/account` or `/v1/models`.
12
+ * Decisive: 200 → key valid, 401/403 → key rejected. Timeouts fall through.
13
+ * 2. Slow path — optional live `/models` discovery, then parallel chat-completion
14
+ * pings against candidate model IDs (up to 10 attempts, 5 parallel).
15
+ *
16
+ * → Functions:
17
+ * - `testProviderKeyDirect` — fast auth-only check
18
+ * - `buildProviderModelsUrl` — derive `/models` from `/chat/completions`
19
+ * - `parseProviderModelIds` — extract ids from OpenAI `/models` response
20
+ * - `listProviderTestModels` — build ordered candidate list
21
+ * - `classifyProviderTestOutcome` — map HTTP codes to outcome label
22
+ * - `buildProviderTestDetail` — human-readable failure explanation
23
+ * - `runProviderKeyTest` — full async pipeline, returns result object
24
+ *
25
+ * @exports testProviderKeyDirect, buildProviderModelsUrl, parseProviderModelIds,
26
+ * listProviderTestModels, classifyProviderTestOutcome, buildProviderTestDetail,
27
+ * runProviderKeyTest, PROVIDER_AUTH_ENDPOINTS
28
+ */
29
+
30
+ import { ping } from './ping.js'
31
+ import { sleep } from './shared-helpers.js'
32
+
33
+ // ─── Constants ────────────────────────────────────────────────────────────────
34
+
35
+ // 📖 Some providers need an explicit probe model because the first catalog entry
36
+ // 📖 is not guaranteed to be accepted by their chat endpoint.
37
+ export const PROVIDER_TEST_MODEL_OVERRIDES = {
38
+ sambanova: ['MiniMax-M2.5', 'DeepSeek-V3.1', 'DeepSeek-V3.2'],
39
+ nvidia: ['deepseek-ai/deepseek-v4-flash', 'openai/gpt-oss-120b'],
40
+ 'github-models': ['openai/gpt-4.1-mini'],
41
+ mistral: ['mistral-small-latest', 'devstral-small-latest'],
42
+ }
43
+
44
+ // 📖 Settings key tests retry retryable failures across several models so a
45
+ // 📖 single stale catalog entry or transient timeout does not mark a valid key as dead.
46
+ const SETTINGS_TEST_MAX_ATTEMPTS = 10
47
+ const SETTINGS_TEST_RETRY_DELAY_MS = 4000
48
+ const SETTINGS_TEST_PARALLEL_PROBES = 5
49
+
50
+ // 📖 PROVIDER_AUTH_ENDPOINTS maps provider keys to their auth-check URL + method.
51
+ // 📖 For most providers this is the /models endpoint (returns 200=valid, 401=invalid).
52
+ // 📖 Providers without an auth-check endpoint use null (falls back to chat completion ping).
53
+ // 📖 Special cases:
54
+ // 📖 - replicate: uses /v1/predictions (not /models) but needs a different payload
55
+ // 📖 - cloudflare: no auth endpoint — only has chat completions, always uses ping fallback
56
+ export const PROVIDER_AUTH_ENDPOINTS = {
57
+ nvidia: { url: 'https://api.nvidia.com/v1/account', method: 'GET' },
58
+ groq: { url: 'https://api.groq.com/v1/models', method: 'GET' },
59
+ cerebras: { url: 'https://api.cerebras.ai/v1/models', method: 'GET' },
60
+ sambanova: { url: 'https://api.sambanova.ai/v1/models', method: 'GET' },
61
+ openrouter: { url: 'https://openrouter.ai/api/v1/key', method: 'GET' },
62
+ mistral: { url: 'https://api.mistral.ai/v1/models', method: 'GET' },
63
+ huggingface: { url: 'https://router.huggingface.co/v1/models', method: 'GET' },
64
+ deepinfra: { url: 'https://api.deepinfra.com/v1/models', method: 'GET' },
65
+ fireworks: { url: 'https://api.fireworks.ai/v1/models', method: 'GET' },
66
+ hyperbolic: { url: 'https://api.hyperbolic.xyz/v1/models', method: 'GET' },
67
+ scaleway: { url: 'https://api.scaleway.ai/v1/models', method: 'GET' },
68
+ siliconflow: { url: 'https://api.siliconflow.com/v1/models', method: 'GET' },
69
+ together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
70
+ perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
71
+ chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
72
+ ovhcloud: { url: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models', method: 'GET' },
73
+ qwen: { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', method: 'GET' },
74
+ iflow: { url: 'https://apis.iflow.cn/v1/models', method: 'GET' },
75
+ 'github-models': null,
76
+ replicate: null,
77
+ cloudflare: null,
78
+ zai: null,
79
+ googleai: null,
80
+ 'opencode-zen': null,
81
+ kilo: { url: 'https://api.kilo.ai/api/gateway/models', method: 'GET' },
82
+ llm7: { url: 'https://api.llm7.io/v1/models', method: 'GET' },
83
+ routeway: { url: 'https://api.routeway.ai/v1/models', method: 'GET' },
84
+ novita: { url: 'https://api.novita.ai/openai/v1/models', method: 'GET' },
85
+ 'ollama-cloud': { url: 'https://ollama.com/v1/models', method: 'GET' },
86
+ }
87
+
88
+ // ─── Auth-only fast probe ────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * 📖 testProviderKeyDirect: Fast auth-only check using /v1/account or /v1/models.
92
+ * 📖 Fires 3 parallel probes to get a fast decisive result (auth error vs timeout vs 200).
93
+ * 📖 Returns { code, ms } from the first non-timeout response, or the best available.
94
+ * @param {string} apiKey
95
+ * @param {string} providerKey
96
+ * @returns {Promise<{ code: number|string, ms: number|string } | null>}
97
+ */
98
+ export async function testProviderKeyDirect(apiKey, providerKey) {
99
+ const authConfig = PROVIDER_AUTH_ENDPOINTS[providerKey]
100
+ if (!authConfig) return null
101
+
102
+ const { url, method } = authConfig
103
+ const headers = { Authorization: `Bearer ${apiKey}` }
104
+ if (providerKey === 'openrouter') {
105
+ headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
106
+ headers['X-Title'] = 'free-coding-models'
107
+ }
108
+
109
+ const parallel = 3
110
+ const promises = Array.from({ length: parallel }, async () => {
111
+ const ctrl = new AbortController()
112
+ const timer = setTimeout(() => ctrl.abort(), 8000)
113
+ const t0 = performance.now()
114
+ try {
115
+ const resp = await fetch(url, { method, headers, signal: ctrl.signal })
116
+ return { code: resp.status, ms: Math.round(performance.now() - t0) }
117
+ } catch (err) {
118
+ const isTimeout = err.name === 'AbortError'
119
+ return { code: isTimeout ? '000' : 'ERR', ms: isTimeout ? 'TIMEOUT' : Math.round(performance.now() - t0) }
120
+ } finally {
121
+ clearTimeout(timer)
122
+ }
123
+ })
124
+
125
+ const results = await Promise.all(promises)
126
+ const success = results.find(r => r.code === 200)
127
+ if (success) return success
128
+ const authFailure = results.find(r => r.code === 401 || r.code === 403)
129
+ if (authFailure) return authFailure
130
+ return results[0]
131
+ }
132
+
133
+ // ─── Model discovery helpers ─────────────────────────────────────────────────
134
+
135
+ /**
136
+ * 📖 buildProviderModelsUrl derives the matching `/models` endpoint for providers
137
+ * 📖 that expose an OpenAI-compatible model list next to `/chat/completions`.
138
+ * @param {string} url
139
+ * @returns {string|null}
140
+ */
141
+ export function buildProviderModelsUrl(url) {
142
+ if (typeof url !== 'string' || !url.includes('/chat/completions')) return null
143
+ return url.replace(/\/chat\/completions$/, '/models')
144
+ }
145
+
146
+ /**
147
+ * 📖 parseProviderModelIds extracts ids from a standard OpenAI-style `/models` response.
148
+ * 📖 Invalid payloads return an empty list so the key-test flow can safely fall back.
149
+ * @param {unknown} data
150
+ * @returns {string[]}
151
+ */
152
+ export function parseProviderModelIds(data) {
153
+ if (!data || typeof data !== 'object' || !Array.isArray(data.data)) return []
154
+ return data.data
155
+ .map(entry => (entry && typeof entry.id === 'string') ? entry.id.trim() : '')
156
+ .filter(Boolean)
157
+ }
158
+
159
+ /**
160
+ * 📖 listProviderTestModels builds the ordered probe list used by the Settings `T` key.
161
+ * 📖 Order matters:
162
+ * 📖 1. provider-specific known-good overrides
163
+ * 📖 2. discovered `/models` ids that also exist in this repo
164
+ * 📖 3. all discovered `/models` ids
165
+ * 📖 4. repo static model ids as final fallback
166
+ * @param {string} providerKey
167
+ * @param {{ models?: Array<[string, string, string, string, string]> } | undefined} src
168
+ * @param {string[]} [discoveredModelIds=[]]
169
+ * @returns {string[]}
170
+ */
171
+ export function listProviderTestModels(providerKey, src, discoveredModelIds = []) {
172
+ const staticModelIds = Array.isArray(src?.models) ? src.models.map(model => model[0]).filter(Boolean) : []
173
+ const staticModelSet = new Set(staticModelIds)
174
+ const preferredDiscoveredIds = discoveredModelIds.filter(modelId => staticModelSet.has(modelId))
175
+ const orderedCandidates = [
176
+ ...(PROVIDER_TEST_MODEL_OVERRIDES[providerKey] ?? []),
177
+ ...preferredDiscoveredIds,
178
+ ...discoveredModelIds,
179
+ ...staticModelIds,
180
+ ]
181
+ return [...new Set(orderedCandidates)]
182
+ }
183
+
184
+ // ─── Outcome classification ──────────────────────────────────────────────────
185
+
186
+ /**
187
+ * 📖 classifyProviderTestOutcome maps attempted probe codes to a user-facing test result.
188
+ * @param {string[]} codes
189
+ * @returns {'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'}
190
+ */
191
+ export function classifyProviderTestOutcome(codes) {
192
+ if (codes.includes('200')) return 'ok'
193
+ if (codes.includes('401') || codes.includes('403')) return 'auth_error'
194
+ if (codes.length > 0 && codes.every(code => code === '429')) return 'rate_limited'
195
+ if (codes.length > 0 && codes.every(code => code === '404' || code === '410')) return 'no_callable_model'
196
+ return 'fail'
197
+ }
198
+
199
+ /**
200
+ * 📖 buildProviderTestDetail explains why the probe failed, with enough context
201
+ * 📖 for the user to know whether the key, model list, or provider quota is the problem.
202
+ * @param {string} providerLabel
203
+ * @param {string} outcome
204
+ * @param {Array<{attempt: number, model: string, code: string}>} [attempts=[]]
205
+ * @param {string} [discoveryNote='']
206
+ * @returns {string}
207
+ */
208
+ export function buildProviderTestDetail(providerLabel, outcome, attempts = [], discoveryNote = '') {
209
+ const introByOutcome = {
210
+ missing_key: `${providerLabel} has no saved API key right now, so no authenticated test could be sent.`,
211
+ ok: `${providerLabel} accepted the key.`,
212
+ auth_error: `${providerLabel} rejected the configured key with an authentication error.`,
213
+ rate_limited: `${providerLabel} throttled every probe, so the key may still be valid but is currently rate-limited.`,
214
+ no_callable_model: `${providerLabel} answered the requests, but none of the probed models were callable on its chat endpoint.`,
215
+ fail: `${providerLabel} never returned a successful probe during the retry window.`,
216
+ }
217
+
218
+ const hintsByOutcome = {
219
+ missing_key: 'Save the key with Enter in Settings, then rerun T.',
220
+ ok: attempts.length > 0 ? `Validated on ${attempts[attempts.length - 1].model}.` : 'The provider returned a success response.',
221
+ auth_error: 'This usually means the saved key is invalid, expired, revoked, or truncated before it reached disk.',
222
+ rate_limited: 'Wait for the provider quota window to reset, then rerun T.',
223
+ no_callable_model: 'The provider catalog or repo defaults likely drifted; try another model family or refresh the catalog.',
224
+ fail: 'This can be caused by timeouts, 5xx responses, or a provider-side outage.',
225
+ }
226
+
227
+ const attemptSummary = attempts.length > 0
228
+ ? `Attempts: ${attempts.map(({ attempt, model, code }) => `#${attempt} ${model} -> ${code}`).join(' | ')}`
229
+ : 'Attempts: none'
230
+
231
+ const segments = [
232
+ introByOutcome[outcome] || introByOutcome.fail,
233
+ hintsByOutcome[outcome] || hintsByOutcome.fail,
234
+ discoveryNote,
235
+ attemptSummary,
236
+ ].filter(Boolean)
237
+
238
+ return segments.join(' ')
239
+ }
240
+
241
+ // ─── Full pipeline ───────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * 📖 runProviderKeyTest: Pure async function that verifies an API key for a provider.
245
+ *
246
+ * 📖 Used by both the TUI Settings `T` key and the Web Dashboard's
247
+ * 📖 `/api/key/:provider/test` endpoint. Returns a result object instead of
248
+ * 📖 mutating TUI state so each surface can decide how to display it.
249
+ *
250
+ * @param {string} apiKey — the API key to test
251
+ * @param {string} providerKey — e.g. 'openrouter', 'groq'
252
+ * @param {{ name?: string, url?: string, models?: Array<[string, string, string, string, string]> }} source — provider entry from sources.js
253
+ * @param {object} [options]
254
+ * @param {number} [options.maxAttempts=10]
255
+ * @param {number} [options.parallelProbes=5]
256
+ * @param {number} [options.retryDelayMs=4000]
257
+ * @param {Function} [options.onProgress] — optional callback({ attempts, maxAttempts }) for live updates
258
+ * @returns {Promise<{ outcome: string, detail: string, attempts: Array, discoveryNote: string }>}
259
+ */
260
+ export async function runProviderKeyTest(apiKey, providerKey, source, options = {}) {
261
+ const {
262
+ maxAttempts = SETTINGS_TEST_MAX_ATTEMPTS,
263
+ parallelProbes = SETTINGS_TEST_PARALLEL_PROBES,
264
+ retryDelayMs = SETTINGS_TEST_RETRY_DELAY_MS,
265
+ onProgress,
266
+ } = options
267
+
268
+ const providerLabel = source?.name || providerKey
269
+
270
+ // 📖 Fast path: parallel auth-only probes (3×8s) to /v1/account or /v1/models.
271
+ const authResult = await testProviderKeyDirect(apiKey, providerKey)
272
+ if (authResult) {
273
+ if (authResult.code === 200) {
274
+ return {
275
+ outcome: 'ok',
276
+ detail: buildProviderTestDetail(providerLabel, 'ok', [], 'Auth-only probe returned HTTP 200.'),
277
+ attempts: [],
278
+ discoveryNote: 'Auth-only probe returned HTTP 200.',
279
+ }
280
+ }
281
+ if (authResult.code === 401 || authResult.code === 403) {
282
+ return {
283
+ outcome: 'auth_error',
284
+ detail: buildProviderTestDetail(providerLabel, 'auth_error', [], `Auth probe returned HTTP ${authResult.code}.`),
285
+ attempts: [],
286
+ discoveryNote: `Auth probe returned HTTP ${authResult.code}.`,
287
+ }
288
+ }
289
+ // 📖 Timeout or ERR — fall through to ping-based approach below.
290
+ }
291
+
292
+ // 📖 Slow path: ping-based verification (providers without auth endpoint or timeouts).
293
+ const discoveredModelIds = []
294
+ const modelsUrl = buildProviderModelsUrl(source?.url)
295
+ let discoveryNote = ''
296
+
297
+ if (modelsUrl) {
298
+ try {
299
+ const headers = { Authorization: `Bearer ${apiKey}` }
300
+ if (providerKey === 'openrouter') {
301
+ headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
302
+ headers['X-Title'] = 'free-coding-models'
303
+ }
304
+ const modelsResp = await fetch(modelsUrl, { headers })
305
+ if (modelsResp.ok) {
306
+ const data = await modelsResp.json()
307
+ discoveredModelIds.push(...parseProviderModelIds(data))
308
+ discoveryNote = discoveredModelIds.length > 0
309
+ ? `Live model discovery returned ${discoveredModelIds.length} ids.`
310
+ : 'Live model discovery succeeded but returned no callable ids.'
311
+ } else {
312
+ discoveryNote = `Live model discovery returned HTTP ${modelsResp.status}; falling back to the repo catalog.`
313
+ }
314
+ } catch (err) {
315
+ discoveryNote = `Live model discovery failed (${err?.name || 'error'}); falling back to the repo catalog.`
316
+ }
317
+ }
318
+
319
+ const candidateModels = listProviderTestModels(providerKey, source, discoveredModelIds)
320
+ if (candidateModels.length === 0) {
321
+ return {
322
+ outcome: 'fail',
323
+ detail: buildProviderTestDetail(providerLabel, 'fail', [], discoveryNote || 'No candidate model was available for probing.'),
324
+ attempts: [],
325
+ discoveryNote: discoveryNote || 'No candidate model was available for probing.',
326
+ }
327
+ }
328
+
329
+ // 📖 Parallel ping burst: fire probes simultaneously to get fast feedback.
330
+ const attempts = []
331
+ let settled = false
332
+
333
+ while (!settled) {
334
+ const batch = []
335
+ for (let i = 0; i < parallelProbes && attempts.length + batch.length < maxAttempts; i++) {
336
+ const testModel = candidateModels[(attempts.length + batch.length) % candidateModels.length]
337
+ batch.push(
338
+ ping(apiKey, testModel, providerKey, source.url)
339
+ .then(({ code }) => ({ attempt: attempts.length + batch.length + 1, model: testModel, code }))
340
+ )
341
+ }
342
+ const batchResults = await Promise.all(batch)
343
+ attempts.push(...batchResults)
344
+
345
+ if (onProgress) onProgress({ attempts: attempts.length, maxAttempts })
346
+
347
+ // 📖 Check outcome after each parallel batch.
348
+ const outcome = classifyProviderTestOutcome(attempts.map(({ code }) => code))
349
+ if (outcome === 'ok' || outcome === 'auth_error') {
350
+ settled = true
351
+ continue
352
+ }
353
+ if (attempts.length >= maxAttempts) {
354
+ settled = true
355
+ continue
356
+ }
357
+
358
+ // 📖 Pause before next round.
359
+ await sleep(retryDelayMs)
360
+ }
361
+
362
+ const finalOutcome = classifyProviderTestOutcome(attempts.map(({ code }) => code))
363
+ return {
364
+ outcome: finalOutcome,
365
+ detail: buildProviderTestDetail(providerLabel, finalOutcome, attempts, discoveryNote),
366
+ attempts,
367
+ discoveryNote,
368
+ }
369
+ }
@@ -1149,15 +1149,27 @@ class RouterRuntime {
1149
1149
  const hasData = stats.total > 0
1150
1150
  const latencyScore = stats.p95 === null ? 0.5 : Math.max(0, 1 - (stats.p95 / maxP95))
1151
1151
  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.
1152
1157
  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.
1153
1164
  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
1156
1167
  const state = this.updateCircuitForCooldown(key) || {}
1157
1168
  return {
1158
1169
  ...entry,
1159
1170
  key,
1160
1171
  score,
1172
+ priorityBonus,
1161
1173
  stats,
1162
1174
  circuit: state,
1163
1175
  catalog: this.modelCatalog.get(key) || null,
@@ -1193,12 +1205,24 @@ class RouterRuntime {
1193
1205
  if (!this.getApiKeyForProvider(candidate.provider)) return false
1194
1206
  return candidate.circuit?.state === 'CLOSED' || candidate.circuit?.state === 'HALF_OPEN'
1195
1207
  })
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)]
1208
+ // 📖 New ordering: prioritize by explicit priority first, then by circuit state
1209
+ // 📖 (CLOSED before HALF_OPEN), and finally by health score (higher is better).
1210
+ // 📖 This ensures a higher‑priority model is never skipped just because it is
1211
+ // 📖 in HALF_OPEN while a lower‑priority CLOSED model is available.
1212
+ const stateOrder = { CLOSED: 0, HALF_OPEN: 1 }
1213
+ const comparator = (a, b) => {
1214
+ if (a.priority !== b.priority) return a.priority - b.priority
1215
+ const aState = a.circuit?.state || 'UNKNOWN'
1216
+ const bState = b.circuit?.state || 'UNKNOWN'
1217
+ if (aState !== bState) {
1218
+ const aRank = stateOrder[aState] ?? 2
1219
+ const bRank = stateOrder[bState] ?? 2
1220
+ return aRank - bRank
1221
+ }
1222
+ // higher score first
1223
+ return b.score - a.score
1224
+ }
1225
+ return usable.sort(comparator)
1202
1226
  }
1203
1227
 
1204
1228
  // 📖 getRoutingOrder - slim projection of getRoutingCandidates for the /stats
@@ -1738,27 +1762,48 @@ class RouterRuntime {
1738
1762
  }
1739
1763
 
1740
1764
  scheduleProbeLoop() {
1765
+ // Clear any existing timers
1741
1766
  if (this.probeTimer) clearInterval(this.probeTimer)
1767
+ if (this.probeWatchdog) clearInterval(this.probeWatchdog)
1742
1768
  for (const timeout of this.probeTimeouts) clearTimeout(timeout)
1743
1769
  this.probeTimeouts.clear()
1770
+
1744
1771
  const router = this.routerConfig()
1745
1772
  const interval = router.probeIntervals[router.probeMode] || DEFAULT_ROUTER_SETTINGS.probeIntervals.balanced
1773
+ // Track last successful probe cycle timestamp
1774
+ this.lastProbeAt = Date.now()
1775
+
1746
1776
  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
- })
1777
+ try {
1778
+ const set = this.getSet()
1779
+ if (!set || this.shuttingDown) return
1780
+ const candidates = this.scoreCandidates(set)
1781
+ .filter((candidate) => candidate.catalog?.routeable && !candidate.circuit?.stale)
1782
+ const stagger = candidates.length > 0 ? Math.max(250, Math.floor(interval / candidates.length)) : interval
1783
+ candidates.forEach((candidate, index) => {
1784
+ const timeout = setTimeout(() => {
1785
+ this.probeTimeouts.delete(timeout)
1786
+ void this.probeCandidate(candidate, { eco: router.probeMode === 'eco' })
1787
+ }, index * stagger)
1788
+ timeout.unref?.()
1789
+ this.probeTimeouts.add(timeout)
1790
+ })
1791
+ // Update timestamp after scheduling probes
1792
+ this.lastProbeAt = Date.now()
1793
+ } catch (err) {
1794
+ this.logger.error('[ProbeLoop] error', { error: err })
1795
+ }
1760
1796
  }, interval)
1761
1797
  this.probeTimer.unref?.()
1798
+
1799
+ // Watchdog: if no successful cycle for 3x interval, restart loop
1800
+ this.probeWatchdog = setInterval(() => {
1801
+ if (this.lastProbeAt && Date.now() - this.lastProbeAt > interval * 3) {
1802
+ this.logger.warn('[ProbeLoop] stall detected, restarting probe loop')
1803
+ this.scheduleProbeLoop()
1804
+ }
1805
+ }, interval)
1806
+ this.probeWatchdog.unref?.()
1762
1807
  }
1763
1808
 
1764
1809
  async routeRequest({ req, res, body, setName, requestId }) {
@@ -2357,6 +2402,7 @@ class RouterRuntime {
2357
2402
  this.markSetCustomized()
2358
2403
  this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: name, action: 'add', model: newEntry })
2359
2404
  sendJson(res, 201, { set: normalized.sets[name], router: normalized }, { 'x-request-id': requestId })
2405
+ void this.runProbeBurst()
2360
2406
  return
2361
2407
  }
2362
2408