free-coding-models 0.5.34 → 0.5.36

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,4 @@
1
+ # Changelog v0.5.35 - 2026-06-17
2
+
3
+ ### Notes
4
+ - **Registry note: 0.5.33 was skipped in the auto-publish.** The 0.5.33 release (ZCode tool mode + web modal refactor) exists in git history as commit `ecf740c` with its full changelog file `changelog/v0.5.33.md`, but the GitHub Actions publish workflow did not register a `0.5.33` package on npm — the registry jumped directly from `0.5.32` to `0.5.34`. Per project policy of never skipping versions, this `0.5.35` release is a registry marker: same code as `0.5.34` (schema normalizer + ZCode + web modal refactor), published to close the version gap. Users who installed `0.5.34` already have all the same functionality. CI was investigated; the root cause is being tracked separately. No code changes in this release.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.34",
3
+ "version": "0.5.36",
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",
@@ -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
+ }
@@ -1193,12 +1193,24 @@ class RouterRuntime {
1193
1193
  if (!this.getApiKeyForProvider(candidate.provider)) return false
1194
1194
  return candidate.circuit?.state === 'CLOSED' || candidate.circuit?.state === 'HALF_OPEN'
1195
1195
  })
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)
1202
1214
  }
1203
1215
 
1204
1216
  // 📖 getRoutingOrder - slim projection of getRoutingCandidates for the /stats
@@ -1738,27 +1750,48 @@ class RouterRuntime {
1738
1750
  }
1739
1751
 
1740
1752
  scheduleProbeLoop() {
1753
+ // Clear any existing timers
1741
1754
  if (this.probeTimer) clearInterval(this.probeTimer)
1755
+ if (this.probeWatchdog) clearInterval(this.probeWatchdog)
1742
1756
  for (const timeout of this.probeTimeouts) clearTimeout(timeout)
1743
1757
  this.probeTimeouts.clear()
1758
+
1744
1759
  const router = this.routerConfig()
1745
1760
  const interval = router.probeIntervals[router.probeMode] || DEFAULT_ROUTER_SETTINGS.probeIntervals.balanced
1761
+ // Track last successful probe cycle timestamp
1762
+ this.lastProbeAt = Date.now()
1763
+
1746
1764
  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
+ }
1760
1784
  }, interval)
1761
1785
  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?.()
1762
1795
  }
1763
1796
 
1764
1797
  async routeRequest({ req, res, body, setName, requestId }) {
@@ -2357,6 +2390,7 @@ class RouterRuntime {
2357
2390
  this.markSetCustomized()
2358
2391
  this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: name, action: 'add', model: newEntry })
2359
2392
  sendJson(res, 201, { set: normalized.sets[name], router: normalized }, { 'x-request-id': requestId })
2393
+ void this.runProbeBurst()
2360
2394
  return
2361
2395
  }
2362
2396