free-coding-models 0.5.11 → 0.5.12

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.
@@ -936,7 +936,11 @@ class RouterRuntime {
936
936
  routerConfig() {
937
937
  const normalized = normalizeRouterConfig(this.config.router)
938
938
  if (normalized) return normalized
939
- const defaultSet = buildDefaultRouterSet(this.config)
939
+ // 📖 Fallback for the very first read before ensureRouterConfigForDaemon
940
+ // 📖 has had a chance to probe candidates. We use a tiny sync helper
941
+ // 📖 here so the routerConfig() getter stays sync. The async probed
942
+ // 📖 version is wired up by runRouterDaemon() on first start.
943
+ const defaultSet = buildDefaultRouterSetSync(this.config)
940
944
  return normalizeRouterConfig({
941
945
  ...DEFAULT_ROUTER_SETTINGS,
942
946
  enabled: true,
@@ -951,6 +955,22 @@ class RouterRuntime {
951
955
  this.refreshRouteState()
952
956
  }
953
957
 
958
+ /**
959
+ * 📖 markSetCustomized — flip `router.userCustomized = true` and
960
+ * 📖 `router.autoHeal = false` so the user's manual edits are
961
+ * 📖 preserved on the next daemon start. Called from the HTTP
962
+ * 📖 endpoints that mutate the active set (add / remove / reorder /
963
+ * 📖 sync / activate / rename). Auto-heal itself does NOT call this.
964
+ */
965
+ markSetCustomized() {
966
+ if (!this.config.router) return
967
+ this.config.router = normalizeRouterConfig({
968
+ ...this.config.router,
969
+ userCustomized: true,
970
+ autoHeal: false,
971
+ })
972
+ }
973
+
954
974
  saveRouterConfig() {
955
975
  if (this.persistConfig === false) return { success: true, backupCreated: false }
956
976
  const result = saveConfig(this.config)
@@ -962,7 +982,7 @@ class RouterRuntime {
962
982
  try {
963
983
  const nextConfig = loadConfig()
964
984
  // 📖 Always rebuild the router set from favorites so UI toggles apply dynamically
965
- ensureRouterConfigForDaemon(nextConfig, true)
985
+ void ensureRouterConfigForDaemon(nextConfig, true)
966
986
  this.config = nextConfig
967
987
  this.refreshRouteState()
968
988
  this.scheduleProbeLoop()
@@ -1332,6 +1352,20 @@ class RouterRuntime {
1332
1352
  setCount: Object.keys(router.sets || {}).length,
1333
1353
  uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
1334
1354
  requestsRouted: this.totalRequestsRouted,
1355
+ // 📖 M6: surface the auto-heal flags so the UI can show "auto-heal
1356
+ // 📖 is on" / "user has customized this set" and the broken-model
1357
+ // 📖 count so the dashboard can prompt for a fix.
1358
+ autoHeal: router.autoHeal !== false,
1359
+ userCustomized: router.userCustomized === true,
1360
+ brokenModelCount: (activeSet?.models || []).filter((m) => {
1361
+ const key = `${m.provider}/${m.model}`
1362
+ // 📖 this.circuit stores the raw flags (authError / stale / unsupported)
1363
+ // 📖 alongside the translated `state`. We read the raw flags so a
1364
+ // 📖 model that just auth-errored (state: CLOSED + authError: true)
1365
+ // 📖 is still flagged as broken, not just one whose state is OPEN.
1366
+ const cb = this.circuit?.get?.(key)
1367
+ return Boolean(cb?.authError || cb?.stale)
1368
+ }).length,
1335
1369
  inFlight: this.inFlight,
1336
1370
  shuttingDown: this.shuttingDown,
1337
1371
  probeMode: router.probeMode,
@@ -1426,6 +1460,233 @@ class RouterRuntime {
1426
1460
  })))
1427
1461
  }
1428
1462
 
1463
+ /**
1464
+ * 📖 autoHealActiveSet — replaces broken models in the active set with
1465
+ * 📖 working alternatives, so the Playground and Router Dashboard both
1466
+ * 📖 start with a usable set by default. The user's manual edits are
1467
+ * 📖 always respected: once `router.userCustomized` is true (set by
1468
+ * 📖 reorder/add/remove/sync), auto-heal is a no-op.
1469
+ *
1470
+ * 📖 Healing strategy:
1471
+ * 📖 1. Identify broken models in the active set
1472
+ * 📖 (state === AUTH_ERROR or persistent TIMEOUT).
1473
+ * 📖 2. For each broken model, pick a working alternative:
1474
+ * 📖 a. Prefer a same-provider model that's currently CLOSED.
1475
+ * 📖 b. Fall back to any keyed-provider model that's CLOSED.
1476
+ * 📖 3. Replace in place, preserving priority order.
1477
+ * 📖 4. Broadcast a `set_change` so the UI refreshes.
1478
+ *
1479
+ * 📖 Should be called once at startup, AFTER the first `runProbeBurst`
1480
+ * 📖 so the circuit-breaker data is fresh.
1481
+ */
1482
+ async autoHealActiveSet() {
1483
+ const router = this.routerConfig()
1484
+ if (router.autoHeal === false) return { ok: false, reason: 'autoHeal_disabled' }
1485
+ if (router.userCustomized === true) return { ok: false, reason: 'user_customized' }
1486
+ const set = this.getSet(router.activeSet)
1487
+ if (!set || !Array.isArray(set.models) || set.models.length === 0) {
1488
+ return { ok: false, reason: 'empty_set' }
1489
+ }
1490
+
1491
+ // 📖 Build a candidate pool from EVERY routeable model in the
1492
+ // 📖 catalog, not just the ones in the active set — we need healthy
1493
+ // 📖 alternatives to swap in, and the active set's only models are
1494
+ // 📖 the broken ones we're trying to replace.
1495
+ const healthByKey = new Map()
1496
+ const aliveByProvider = new Map()
1497
+ // 📖 Per-provider probe stats — we use these to detect "the user's
1498
+ // 📖 whole <provider> is dead" (every probe has auth-errored) and
1499
+ // 📖 skip that provider as a candidate for replacements.
1500
+ const providerProbeStats = new Map() // provider -> { probed: n, authError: n, stale: n, alive: n }
1501
+ for (const [providerKey, source] of Object.entries(sources)) {
1502
+ if (!isRouteableProvider(providerKey)) continue
1503
+ if (!providerProbeStats.has(providerKey)) providerProbeStats.set(providerKey, { probed: 0, authError: 0, stale: 0, alive: 0 })
1504
+ for (const [modelId, , tier, sweScore, ctx] of source.models || []) {
1505
+ const key = `${providerKey}/${modelId}`
1506
+ const cb = this.circuit?.get?.(key) || {}
1507
+ const state = cb.state || 'UNKNOWN'
1508
+ const authError = !!cb.authError
1509
+ const stale = !!cb.stale
1510
+ const isAlive = !cb.lastErrorAt ? true : (state === 'CLOSED' && !authError && !stale)
1511
+ const tierRank = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C'].indexOf(tier)
1512
+ const score = Number.isFinite(tierRank) && tierRank >= 0
1513
+ ? (10 - tierRank) * 100 + (Number.parseFloat(sweScore) || 0)
1514
+ : 0
1515
+ healthByKey.set(key, { state, authError, stale, isAlive, score, existsInCatalog: true })
1516
+ // 📖 Only count "alive" picks if we have actual evidence (i.e.
1517
+ // 📖 at least one probe has CLOSED). Otherwise the provider is
1518
+ // 📖 unproven and we treat it as a fallback, not a preferred pick.
1519
+ if (cb.lastErrorAt) {
1520
+ const stats = providerProbeStats.get(providerKey)
1521
+ stats.probed += 1
1522
+ if (authError) stats.authError += 1
1523
+ else if (stale) stats.stale += 1
1524
+ else if (state === 'CLOSED') stats.alive += 1
1525
+ }
1526
+ if (isAlive) {
1527
+ if (!aliveByProvider.has(providerKey)) aliveByProvider.set(providerKey, [])
1528
+ aliveByProvider.get(providerKey).push({ key, score })
1529
+ }
1530
+ }
1531
+ }
1532
+
1533
+ // 📖 Build the "proven-alive" provider set: providers where at
1534
+ // 📖 least one probe has come back CLOSED (not auth-errored or
1535
+ // 📖 stale). Providers with zero proven-alive models are filtered
1536
+ // 📖 out of `aliveByProvider` so the auto-heal doesn't pick unproven
1537
+ // 📖 candidates and end up with another broken replacement.
1538
+ for (const [providerKey, stats] of providerProbeStats.entries()) {
1539
+ if (stats.probed > 0 && stats.alive === 0) {
1540
+ // 📖 Probed but no model ever returned CLOSED → the user's key
1541
+ // 📖 for this provider is dead. Drop all candidates from this
1542
+ // 📖 provider so the picker falls through to a working one.
1543
+ aliveByProvider.delete(providerKey)
1544
+ // 📖 Also flip every model in this provider to "broken" so the
1545
+ // 📖 cross-provider fallback also skips them.
1546
+ for (const entry of healthByKey.entries()) {
1547
+ if (entry[0].startsWith(`${providerKey}/`)) {
1548
+ entry[1].authError = true
1549
+ entry[1].stale = false
1550
+ entry[1].isAlive = false
1551
+ }
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+ // 📖 Also pick up models that are in the active set but NOT in the
1557
+ // 📖 current catalog (e.g. removed from sources.js, deprecated by the
1558
+ // 📖 provider). They should be marked as broken and replaced too —
1559
+ // 📖 otherwise they'd stay in the set forever as silent dead weight.
1560
+ for (const entry of set.models) {
1561
+ const key = `${entry.provider}/${entry.model}`
1562
+ if (!healthByKey.has(key)) {
1563
+ const cb = this.circuit?.get?.(key) || {}
1564
+ healthByKey.set(key, {
1565
+ state: cb.state || 'UNKNOWN',
1566
+ authError: !!cb.authError,
1567
+ stale: true, // 📖 if it's in the set but not in the catalog, it's stale by definition
1568
+ isAlive: false,
1569
+ score: 0,
1570
+ existsInCatalog: false,
1571
+ })
1572
+ }
1573
+ }
1574
+
1575
+ // 📖 Decide what's broken. We heal AUTH_ERROR (key is wrong for that
1576
+ // 📖 model) and STALE/TIMEOUT (upstream isn't responding). We do
1577
+ // 📖 NOT heal HALF_OPEN (recovering) or OPEN (circuit breaker tripped
1578
+ // 📖 on a transient blip) — those should resolve on their own.
1579
+ const isBroken = (key) => {
1580
+ const health = healthByKey.get(key)
1581
+ if (!health) return false
1582
+ return health.authError === true || health.stale === true || health.state === 'STALE' || health.state === 'UNSUPPORTED'
1583
+ }
1584
+
1585
+ const broken = set.models.filter((m) => isBroken(`${m.provider}/${m.model}`))
1586
+ if (broken.length === 0) return { ok: true, replaced: 0, reason: 'no_broken_models' }
1587
+
1588
+ // 📖 Build the replacement list. Same provider first, then any.
1589
+ // 📖 We skip candidates that the circuit breaker already knows are
1590
+ // 📖 broken (authError / stale) so we don't swap a broken model for
1591
+ // 📖 another broken model of the same provider.
1592
+ const usedKeys = new Set(set.models.map((m) => `${m.provider}/${m.model}`))
1593
+ // 📖 Aggregate PROVEN-alive counts per provider so we can detect
1594
+ // 📖 "the user's whole <provider> is dead" and fall through to
1595
+ // 📖 cross-provider candidates instead of stacking broken picks.
1596
+ // 📖 We deliberately ignore unprobed models here so a provider with
1597
+ // 📖 23 unprobed models doesn't look "alive" just because none of
1598
+ // 📖 them have been probed yet. The real signal is the set of models
1599
+ // 📖 we have actual evidence for (i.e. stats.alive > 0).
1600
+ const aliveByProviderKey = (provider) => {
1601
+ const stats = providerProbeStats.get(provider)
1602
+ if (!stats || stats.alive === 0) return 0
1603
+ return stats.alive
1604
+ }
1605
+ const replacements = []
1606
+ for (const dead of broken) {
1607
+ const isPickedBroken = (key) => {
1608
+ const h = healthByKey.get(key)
1609
+ return !h || h.authError === true || h.stale === true
1610
+ }
1611
+ // 📖 If the user's whole <provider> is dead, skip same-provider
1612
+ // 📖 entirely and let anyProvider find a working alternative.
1613
+ const providerIsDead = aliveByProviderKey(dead.provider) === 0
1614
+ const sameProvider = providerIsDead ? null : (aliveByProvider.get(dead.provider) || []).find((c) =>
1615
+ !usedKeys.has(c.key)
1616
+ && !broken.some((b) => `${b.provider}/${b.model}` === c.key)
1617
+ && !isPickedBroken(c.key)
1618
+ )
1619
+ const anyProvider = []
1620
+ for (const [, list] of aliveByProvider) {
1621
+ for (const entry of list) anyProvider.push(entry)
1622
+ }
1623
+ anyProvider.sort((a, b) => b.score - a.score)
1624
+ const pick = sameProvider || anyProvider.find((c) => !usedKeys.has(c.key) && !isPickedBroken(c.key))
1625
+ if (pick) {
1626
+ usedKeys.add(pick.key)
1627
+ const slashIdx = pick.key.indexOf('/')
1628
+ const provider = slashIdx >= 0 ? pick.key.slice(0, slashIdx) : pick.key
1629
+ const model = slashIdx >= 0 ? pick.key.slice(slashIdx + 1) : ''
1630
+ replacements.push({ from: `${dead.provider}/${dead.model}`, to: pick.key, provider, model, score: pick.score })
1631
+ this.logger.info('autoHeal: picked replacement', {
1632
+ from: `${dead.provider}/${dead.model}`,
1633
+ to: pick.key,
1634
+ score: pick.score,
1635
+ sameProvider: pick === sameProvider,
1636
+ crossProvider: !providerIsDead && pick !== sameProvider,
1637
+ providerWasDead: providerIsDead,
1638
+ })
1639
+ } else {
1640
+ const aliveList = Array.from(aliveByProvider.entries()).map(([p, list]) => `${p}:${list.length}`).slice(0, 5)
1641
+ this.logger.warn('autoHeal: no working alternative found', {
1642
+ broken: `${dead.provider}/${dead.model}`,
1643
+ set: set.name,
1644
+ usedKeys: Array.from(usedKeys).slice(0, 10),
1645
+ aliveSample: aliveList,
1646
+ })
1647
+ }
1648
+ }
1649
+
1650
+ if (replacements.length === 0) {
1651
+ return { ok: true, replaced: 0, reason: 'no_working_alternatives' }
1652
+ }
1653
+
1654
+ // 📖 Apply replacements in place, preserving priority order. We
1655
+ // 📖 rewrite the entire `models` array (rather than mutating each
1656
+ // 📖 entry) so priorities stay 1..N and contiguous.
1657
+ const nextModels = []
1658
+ for (const m of set.models) {
1659
+ const key = `${m.provider}/${m.model}`
1660
+ const replacement = replacements.find((r) => r.from === key)
1661
+ if (replacement) {
1662
+ nextModels.push({ provider: replacement.provider, model: replacement.model, priority: nextModels.length + 1 })
1663
+ } else {
1664
+ nextModels.push({ ...m, priority: nextModels.length + 1 })
1665
+ }
1666
+ }
1667
+ const nextRouter = normalizeRouterConfig({
1668
+ ...router,
1669
+ sets: { ...router.sets, [set.name]: { ...set, models: nextModels } },
1670
+ })
1671
+ this.setRouterConfig(nextRouter)
1672
+ this.saveRouterConfig()
1673
+ for (const r of replacements) {
1674
+ this.logger.info('autoHeal: replaced broken model', {
1675
+ from: r.from,
1676
+ to: r.to,
1677
+ score: r.score,
1678
+ set: set.name,
1679
+ })
1680
+ }
1681
+ this.broadcast('set_change', {
1682
+ activeSet: this.routerConfig().activeSet,
1683
+ set: set.name,
1684
+ action: 'auto_heal',
1685
+ replaced: replacements,
1686
+ })
1687
+ return { ok: true, replaced: replacements.length, replacements }
1688
+ }
1689
+
1429
1690
  scheduleProbeLoop() {
1430
1691
  if (this.probeTimer) clearInterval(this.probeTimer)
1431
1692
  for (const timeout of this.probeTimeouts) clearTimeout(timeout)
@@ -1904,6 +2165,9 @@ class RouterRuntime {
1904
2165
  const router = this.routerConfig()
1905
2166
  const setNameMatch = url.pathname.match(/^\/sets\/([^/]+)$/)
1906
2167
  const activateMatch = url.pathname.match(/^\/sets\/([^/]+)\/activate$/)
2168
+ const setModelsMatch = url.pathname.match(/^\/sets\/([^/]+)\/models$/)
2169
+ const setReorderMatch = url.pathname.match(/^\/sets\/([^/]+)\/reorder$/)
2170
+ const setSyncMatch = url.pathname.match(/^\/sets\/([^/]+)\/sync$/)
1907
2171
 
1908
2172
  if (req.method === 'GET' && url.pathname === '/sets') {
1909
2173
  sendJson(res, 200, { activeSet: router.activeSet, sets: router.sets })
@@ -1930,6 +2194,7 @@ class RouterRuntime {
1930
2194
  })
1931
2195
  this.setRouterConfig(normalized)
1932
2196
  this.saveRouterConfig()
2197
+ this.markSetCustomized()
1933
2198
  this.broadcast('set_change', { old_set: router.activeSet, new_set: normalized.activeSet })
1934
2199
  sendJson(res, 201, { set: normalized.sets[normalized.activeSet] || normalized.sets[name], router: normalized })
1935
2200
  return
@@ -1943,6 +2208,7 @@ class RouterRuntime {
1943
2208
  }
1944
2209
  this.setRouterConfig({ ...router, activeSet: name })
1945
2210
  this.saveRouterConfig()
2211
+ this.markSetCustomized()
1946
2212
  this.broadcast('set_change', { old_set: router.activeSet, new_set: name })
1947
2213
  void this.runProbeBurst()
1948
2214
  sendJson(res, 200, { activeSet: name })
@@ -1969,6 +2235,7 @@ class RouterRuntime {
1969
2235
  const normalized = normalizeRouterConfig({ ...router, activeSet: nextActiveSet, sets: nextSets })
1970
2236
  this.setRouterConfig(normalized)
1971
2237
  this.saveRouterConfig()
2238
+ this.markSetCustomized()
1972
2239
  sendJson(res, 200, { set: normalized.sets[nextName], router: normalized })
1973
2240
  return
1974
2241
  }
@@ -1984,13 +2251,189 @@ class RouterRuntime {
1984
2251
  const nextActiveSet = router.activeSet === name ? (Object.keys(nextSets)[0] || DEFAULT_ROUTER_SETTINGS.activeSet) : router.activeSet
1985
2252
  this.setRouterConfig({ ...router, activeSet: nextActiveSet, sets: nextSets })
1986
2253
  this.saveRouterConfig()
2254
+ this.markSetCustomized()
1987
2255
  sendJson(res, 200, { deleted: name, activeSet: this.routerConfig().activeSet })
1988
2256
  return
1989
2257
  }
1990
2258
 
2259
+ // 📖 POST /sets/:name/models — append a single model to a set. The model
2260
+ // 📖 is auto-prioritized to the end of the list (priority = count+1).
2261
+ // 📖 This is the granular alternative to PUT /sets/:name for clients
2262
+ // 📖 that just want to add one entry without resending the full array.
2263
+ if (setModelsMatch && req.method === 'POST') {
2264
+ const name = decodeURIComponent(setModelsMatch[1])
2265
+ const set = router.sets[name]
2266
+ if (!set) {
2267
+ sendError(res, 404, `Router set not found: ${name}`, 'invalid_request_error', 'set_not_found', requestId)
2268
+ return
2269
+ }
2270
+ const body = await readJsonBody(req)
2271
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : ''
2272
+ const model = typeof body.model === 'string' ? body.model.trim() : ''
2273
+ if (!provider || !model) {
2274
+ sendError(res, 400, 'Both `provider` and `model` are required', 'invalid_request_error', 'missing_model_fields', requestId)
2275
+ return
2276
+ }
2277
+ // 📖 Reject duplicate entries by provider+model so the set never
2278
+ // 📖 contains the same key twice (would just waste a priority slot).
2279
+ const currentModels = Array.isArray(set.models) ? set.models : []
2280
+ const duplicate = currentModels.find((m) => m.provider === provider && m.model === model)
2281
+ if (duplicate) {
2282
+ sendError(res, 409, `Model already in set: ${provider}/${model}`, 'invalid_request_error', 'duplicate_model', requestId)
2283
+ return
2284
+ }
2285
+ const newEntry = {
2286
+ provider,
2287
+ model,
2288
+ priority: typeof body.priority === 'number' && Number.isFinite(body.priority)
2289
+ ? body.priority
2290
+ : currentModels.length + 1,
2291
+ }
2292
+ const nextModels = [...currentModels, newEntry]
2293
+ // 📖 Re-number priorities so they're always 1..N and contiguous.
2294
+ for (let i = 0; i < nextModels.length; i += 1) {
2295
+ nextModels[i] = { ...nextModels[i], priority: i + 1 }
2296
+ }
2297
+ const nextSets = { ...router.sets, [name]: { ...set, models: nextModels } }
2298
+ const normalized = normalizeRouterConfig({ ...router, sets: nextSets })
2299
+ this.setRouterConfig(normalized)
2300
+ this.saveRouterConfig()
2301
+ this.markSetCustomized()
2302
+ this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: name, action: 'add', model: newEntry })
2303
+ sendJson(res, 201, { set: normalized.sets[name], router: normalized }, { 'x-request-id': requestId })
2304
+ return
2305
+ }
2306
+
2307
+ // 📖 DELETE /sets/:name/models — remove a single model from a set.
2308
+ // 📖 The body is `{ provider, model }` (using the body keeps the URL
2309
+ // 📖 short and matches the POST shape).
2310
+ if (setModelsMatch && req.method === 'DELETE') {
2311
+ const name = decodeURIComponent(setModelsMatch[1])
2312
+ const set = router.sets[name]
2313
+ if (!set) {
2314
+ sendError(res, 404, `Router set not found: ${name}`, 'invalid_request_error', 'set_not_found', requestId)
2315
+ return
2316
+ }
2317
+ const body = await readJsonBody(req)
2318
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : ''
2319
+ const model = typeof body.model === 'string' ? body.model.trim() : ''
2320
+ if (!provider || !model) {
2321
+ sendError(res, 400, 'Both `provider` and `model` are required', 'invalid_request_error', 'missing_model_fields', requestId)
2322
+ return
2323
+ }
2324
+ const currentModels = Array.isArray(set.models) ? set.models : []
2325
+ const nextModels = currentModels.filter((m) => !(m.provider === provider && m.model === model))
2326
+ if (nextModels.length === currentModels.length) {
2327
+ sendError(res, 404, `Model not in set: ${provider}/${model}`, 'invalid_request_error', 'model_not_in_set', requestId)
2328
+ return
2329
+ }
2330
+ // 📖 Re-number priorities so they stay 1..N and contiguous.
2331
+ for (let i = 0; i < nextModels.length; i += 1) {
2332
+ nextModels[i] = { ...nextModels[i], priority: i + 1 }
2333
+ }
2334
+ const nextSets = { ...router.sets, [name]: { ...set, models: nextModels } }
2335
+ const normalized = normalizeRouterConfig({ ...router, sets: nextSets })
2336
+ this.setRouterConfig(normalized)
2337
+ this.saveRouterConfig()
2338
+ this.markSetCustomized()
2339
+ this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: name, action: 'remove', key: `${provider}/${model}` })
2340
+ sendJson(res, 200, { set: normalized.sets[name], router: normalized }, { 'x-request-id': requestId })
2341
+ return
2342
+ }
2343
+
2344
+ // 📖 POST /sets/:name/reorder — accept a full priority order from the
2345
+ // 📖 client. Body shape: `{ order: ["provider/model", "provider/model"] }`.
2346
+ // 📖 The daemon re-derives the canonical `{ provider, model, priority }`
2347
+ // 📖 objects from the order, so the client never has to know the
2348
+ // 📖 internal `priority` numbering.
2349
+ if (setReorderMatch && req.method === 'POST') {
2350
+ const name = decodeURIComponent(setReorderMatch[1])
2351
+ const set = router.sets[name]
2352
+ if (!set) {
2353
+ sendError(res, 404, `Router set not found: ${name}`, 'invalid_request_error', 'set_not_found', requestId)
2354
+ return
2355
+ }
2356
+ const body = await readJsonBody(req)
2357
+ const order = Array.isArray(body.order) ? body.order : null
2358
+ if (!order) {
2359
+ sendError(res, 400, 'Body must include `order` array', 'invalid_request_error', 'missing_order', requestId)
2360
+ return
2361
+ }
2362
+ const currentModels = Array.isArray(set.models) ? set.models : []
2363
+ const modelByKey = new Map(currentModels.map((m) => [`${m.provider}/${m.model}`, m]))
2364
+ // 📖 Validate that every key in the new order is already in the set.
2365
+ // 📖 Reject unknown keys (would be a silent bug if we just appended).
2366
+ for (const key of order) {
2367
+ if (typeof key !== 'string' || !modelByKey.has(key)) {
2368
+ sendError(res, 400, `Unknown model in order: ${key}`, 'invalid_request_error', 'unknown_model_in_order', requestId)
2369
+ return
2370
+ }
2371
+ }
2372
+ // 📖 Reject the request if the client omitted some keys — reordering
2373
+ // 📖 must be a permutation of the current set, not a partial edit.
2374
+ if (order.length !== currentModels.length) {
2375
+ sendError(res, 400, 'Order must include every model in the set', 'invalid_request_error', 'order_size_mismatch', requestId)
2376
+ return
2377
+ }
2378
+ const nextModels = order.map((key, idx) => ({ ...modelByKey.get(key), priority: idx + 1 }))
2379
+ const nextSets = { ...router.sets, [name]: { ...set, models: nextModels } }
2380
+ const normalized = normalizeRouterConfig({ ...router, sets: nextSets })
2381
+ this.setRouterConfig(normalized)
2382
+ this.saveRouterConfig()
2383
+ this.markSetCustomized()
2384
+ this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: name, action: 'reorder', order: order.slice() })
2385
+ sendJson(res, 200, { set: normalized.sets[name], router: normalized }, { 'x-request-id': requestId })
2386
+ return
2387
+ }
2388
+
1991
2389
  sendError(res, 404, 'Not found', 'invalid_request_error', 'not_found', requestId)
1992
2390
  }
1993
2391
 
2392
+ /**
2393
+ * 📖 POST /sets/:name/sync — re-run the probe-based sync-set pipeline
2394
+ * 📖 against the named set. The pipeline probes up to `maxProbes` model
2395
+ * 📖 candidates with the user's actual API keys and rebuilds the set
2396
+ * 📖 with only the ones that come back 2xx. Returns the new set + a
2397
+ * 📖 sample of probe results so the UI can show "what changed".
2398
+ */
2399
+ async handleSyncSetRequest(req, res, requestId) {
2400
+ const url = req.url ? new URL(req.url, 'http://localhost') : null
2401
+ const pathname = url ? url.pathname : ''
2402
+ const setSyncMatch = pathname.match(/^\/sets\/([^/]+)\/sync$/)
2403
+ if (!setSyncMatch) {
2404
+ sendError(res, 404, 'Not found', 'invalid_request_error', 'not_found', requestId)
2405
+ return
2406
+ }
2407
+ const setName = decodeURIComponent(setSyncMatch[1])
2408
+ try {
2409
+ const { syncSet } = await import('./sync-set.js')
2410
+ // 📖 Bound the probe budget to 16 so a sync from the Web UI never
2411
+ // 📖 takes more than ~60s. The CLI's `free-coding-models --sync-set`
2412
+ // 📖 still uses the larger default for the headless sync pipeline.
2413
+ const result = await syncSet({ name: setName, activate: true, maxProbes: 16, targetCount: 5 })
2414
+ // 📖 sync-set writes to the config file; reload so the daemon's
2415
+ // 📖 in-memory router state picks up the new models immediately
2416
+ // 📖 instead of waiting for the 10s config-reload tick.
2417
+ this.reloadConfigFromDisk()
2418
+ this.markSetCustomized()
2419
+ this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: setName, action: 'sync', count: result.selected?.length || 0 })
2420
+ // 📖 Kick a probe burst so the freshly-added models are pinged and
2421
+ // 📖 their circuit-breaker state is up to date by the time the UI
2422
+ // 📖 re-fetches /api/router/stats.
2423
+ void this.runProbeBurst()
2424
+ sendJson(res, 200, {
2425
+ ok: result.ok !== false,
2426
+ name: setName,
2427
+ selected: result.selected || [],
2428
+ reusedExisting: result.reusedExisting || false,
2429
+ probeCount: result.probeResults?.length || 0,
2430
+ probeResults: (result.probeResults || []).slice(0, 24),
2431
+ }, { 'x-request-id': requestId })
2432
+ } catch (err) {
2433
+ sendError(res, 500, `Sync failed: ${err?.message || String(err)}`, 'server_error', 'sync_failed', requestId)
2434
+ }
2435
+ }
2436
+
1994
2437
  async handleProbeModeRequest(req, res, requestId) {
1995
2438
  const body = await readJsonBody(req)
1996
2439
  const nextProbeMode = typeof body.probeMode === 'string'
@@ -2079,6 +2522,12 @@ class RouterRuntime {
2079
2522
  return
2080
2523
  }
2081
2524
  if (url.pathname === '/sets' || url.pathname.startsWith('/sets/')) {
2525
+ // 📖 /sets/:name/sync has a different return type (rebuilds the
2526
+ // 📖 set from probes) so it gets its own handler.
2527
+ if (/^\/sets\/[^/]+\/sync$/.test(url.pathname) && req.method === 'POST') {
2528
+ await this.handleSyncSetRequest(req, res, requestId)
2529
+ return
2530
+ }
2082
2531
  await this.handleSetsRequest(req, res, url, requestId)
2083
2532
  return
2084
2533
  }
@@ -2088,6 +2537,32 @@ class RouterRuntime {
2088
2537
  sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
2089
2538
  return
2090
2539
  }
2540
+ // 📖 /api/router/catalog — lightweight catalog of routeable models for
2541
+ // 📖 the Web Router Dashboard's "Add model" picker. Returns one row
2542
+ // 📖 per (provider, model) with `key`, label, tier, ctx. We filter to
2543
+ // 📖 routeable providers only so the picker never offers a model the
2544
+ // 📖 daemon cannot actually proxy.
2545
+ if (req.method === 'GET' && url.pathname === '/api/router/catalog') {
2546
+ const rows = []
2547
+ for (const [providerKey, source] of Object.entries(sources)) {
2548
+ if (!isRouteableProvider(providerKey)) continue
2549
+ if (!Array.isArray(source.models)) continue
2550
+ for (const [modelId, label, tier, sweScore, ctx] of source.models) {
2551
+ rows.push({
2552
+ key: `${providerKey}/${modelId}`,
2553
+ provider: providerKey,
2554
+ model: modelId,
2555
+ label: label || modelId,
2556
+ tier: tier || null,
2557
+ sweScore: typeof sweScore === 'number' ? sweScore : null,
2558
+ ctx: ctx || null,
2559
+ hasKey: !!this.getApiKeyForProvider(providerKey),
2560
+ })
2561
+ }
2562
+ }
2563
+ sendJson(res, 200, { models: rows, count: rows.length }, { 'x-request-id': requestId })
2564
+ return
2565
+ }
2091
2566
  if (req.method === 'GET' && url.pathname === '/api/state') {
2092
2567
  sendJson(res, 200, getWebStatePayload(this), { 'x-request-id': requestId })
2093
2568
  return
@@ -2373,17 +2848,48 @@ class RouterRuntime {
2373
2848
  }
2374
2849
  }
2375
2850
 
2851
+ // 📖 Pinned picks: only used as a *tie-breaker* when multiple models have
2852
+ // 📖 identical (tier, sweScore, latency) — never a hard requirement, so
2853
+ // 📖 a user whose NVIDIA key is dead still gets a working set.
2376
2854
  const PREFERRED_DEFAULT_MODELS = [
2377
- { provider: 'nvidia', model: 'minimaxai/minimax-m2.7' },
2378
- { provider: 'nvidia', model: 'z-ai/glm-5.1' },
2379
- { provider: 'nvidia', model: 'deepseek-ai/deepseek-v4-flash' },
2380
- { provider: 'nvidia', model: 'openai/gpt-oss-120b' },
2855
+ { provider: 'groq', model: 'llama-3.3-70b-versatile' },
2856
+ { provider: 'groq', model: 'openai/gpt-oss-120b' },
2857
+ { provider: 'cerebras', model: 'llama3.1-70b' },
2858
+ { provider: 'nvidia', model: 'deepseek-ai/deepseek-v4-flash' },
2859
+ { provider: 'cerebras', model: 'qwen-3-235b-a7b' },
2860
+ { provider: 'nvidia', model: 'openai/gpt-oss-120b' },
2861
+ { provider: 'groq', model: 'llama-3.1-8b-instant' },
2862
+ { provider: 'nvidia', model: 'minimaxai/minimax-m2.7' },
2381
2863
  ]
2382
2864
 
2383
- export function buildDefaultRouterSet(config = {}, maxModels = 5) {
2865
+ /**
2866
+ * 📖 buildDefaultRouterSet picks the first-time set the daemon creates when
2867
+ * 📖 the user has no router config yet. The new behavior is *probe-driven*:
2868
+ * 📖 every candidate model is sent a real chat-completion ping (1 token)
2869
+ * 📖 against the user's actual API key. Models that come back 2xx with a
2870
+ * 📖 reasonable latency go to the top of the list. Models that auth-fail,
2871
+ * 📖 timeout, or 5xx are de-prioritized so a new user with a half-broken
2872
+ * 📖 key set still gets a working default.
2873
+ *
2874
+ * 📖 The probe runs sequentially with a short timeout (1.5s per model) and
2875
+ * 📖 is bounded to ~24 candidates so first-time start stays snappy. If no
2876
+ * 📖 probe fn is provided (e.g. in unit tests) we fall back to the static
2877
+ * 📖 tier-based ordering from the old logic.
2878
+ *
2879
+ * @param {object} config
2880
+ * @param {number} maxModels
2881
+ * @param {object} [options] { probeFn: async (entry) => ({ ok, latencyMs, code }) }
2882
+ * @returns {{ name: string, models: Array, created: string }}
2883
+ */
2884
+ export async function buildDefaultRouterSet(config = {}, maxModels = 5, options = {}) {
2885
+ const probeFn = typeof options.probeFn === 'function' ? options.probeFn : null
2886
+ const probeTimeoutMs = typeof options.probeTimeoutMs === 'number' ? options.probeTimeoutMs : 1500
2887
+ const probeBudget = typeof options.probeBudget === 'number' ? options.probeBudget : 24
2888
+
2384
2889
  const keyedProviders = new Set(Object.entries(config.apiKeys || {})
2385
2890
  .filter(([, value]) => (Array.isArray(value) ? value.length > 0 : typeof value === 'string' && value.trim()))
2386
2891
  .map(([provider]) => provider))
2892
+
2387
2893
  const entries = []
2388
2894
  for (const [providerKey, source] of Object.entries(sources)) {
2389
2895
  if (!isRouteableProvider(providerKey)) continue
@@ -2399,29 +2905,101 @@ export function buildDefaultRouterSet(config = {}, maxModels = 5) {
2399
2905
  })
2400
2906
  }
2401
2907
  }
2402
- const preferred = entries.some((entry) => entry.hasKey)
2403
- ? entries.filter((entry) => entry.hasKey)
2404
- : entries
2405
- const pinned = []
2406
- const allRemaining = [...entries]
2407
- for (const pref of PREFERRED_DEFAULT_MODELS) {
2408
- const idx = allRemaining.findIndex((e) => e.provider === pref.provider && e.model === pref.model)
2409
- if (idx >= 0) {
2410
- pinned.push(allRemaining.splice(idx, 1)[0])
2411
- }
2908
+
2909
+ // 📖 Tier rank for sorting (lower index = better).
2910
+ const tierRank = (tier) => {
2911
+ const idx = TIER_ORDER.indexOf(tier)
2912
+ return idx === -1 ? TIER_ORDER.length : idx
2412
2913
  }
2413
- const remaining = preferred.filter((e) => !pinned.some((p) => p.provider === e.provider && p.model === e.model))
2414
- remaining.sort((a, b) => {
2415
- const tierCmp = TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)
2914
+
2915
+ // 📖 Static fallback ordering (the pre-probe behavior). Used when no probe
2916
+ // 📖 fn is supplied OR when the probe returns no successful candidates.
2917
+ const staticOrder = (a, b) => {
2918
+ if (a.hasKey !== b.hasKey) return a.hasKey ? -1 : 1
2919
+ const tierCmp = tierRank(a.tier) - tierRank(b.tier)
2416
2920
  if (tierCmp !== 0) return tierCmp
2417
2921
  const sweA = Number.parseFloat(a.sweScore) || 0
2418
2922
  const sweB = Number.parseFloat(b.sweScore) || 0
2419
2923
  return sweB - sweA
2420
- })
2421
- const ordered = [...pinned, ...remaining]
2924
+ }
2925
+
2926
+ // 📖 Probe each candidate when a probe fn is available. Successful +
2927
+ // 📖 fast probes are pinned to the top; failed probes fall back to the
2928
+ // 📖 static ordering so the user is never left with an empty set.
2929
+ let probeResults = new Map()
2930
+ if (probeFn) {
2931
+ const candidates = entries
2932
+ .filter((e) => e.hasKey)
2933
+ .sort(staticOrder)
2934
+ .slice(0, probeBudget)
2935
+ const results = await Promise.all(candidates.map(async (entry) => {
2936
+ try {
2937
+ const result = await Promise.race([
2938
+ probeFn(entry),
2939
+ new Promise((resolve) => setTimeout(() => resolve({ ok: false, code: 'TIMEOUT', latencyMs: probeTimeoutMs }), probeTimeoutMs)),
2940
+ ])
2941
+ return { entry, result: result || { ok: false, code: 'NO_RESULT' } }
2942
+ } catch (err) {
2943
+ return { entry, result: { ok: false, code: 'ERR', error: err?.message || String(err) } }
2944
+ }
2945
+ }))
2946
+ for (const { entry, result } of results) {
2947
+ probeResults.set(`${entry.provider}/${entry.model}`, result)
2948
+ }
2949
+ }
2950
+
2951
+ const probeScore = (entry) => {
2952
+ const result = probeResults.get(`${entry.provider}/${entry.model}`)
2953
+ if (!result) return null
2954
+ if (result.ok !== true) return null
2955
+ const latency = Number.isFinite(result.latencyMs) ? result.latencyMs : 9999
2956
+ // 📖 Higher is better: tier weight + speed bonus. We use tier rank to
2957
+ // 📖 make sure S+ and S still outrank A even when A is faster.
2958
+ const tierWeight = (TIER_ORDER.length - tierRank(entry.tier)) * 1000
2959
+ const speedBonus = Math.max(0, 5000 - latency)
2960
+ return tierWeight + speedBonus
2961
+ }
2962
+
2963
+ const working = entries
2964
+ .map((entry) => ({ entry, score: probeScore(entry) }))
2965
+ .filter((x) => x.score != null)
2966
+ .sort((a, b) => b.score - a.score)
2967
+
2968
+ const failing = entries
2969
+ .filter((e) => !probeResults.has(`${e.provider}/${e.model}`) || probeScore(e) == null)
2970
+ .sort(staticOrder)
2971
+
2972
+ // 📖 Build the final order: proven-working models first, then the static
2973
+ // 📖 fallback, then pinned popular models as a safety net so the user
2974
+ // 📖 always sees a populated set on first start.
2975
+ const used = new Set()
2976
+ const ordered = []
2977
+ for (const { entry } of working) {
2978
+ const key = `${entry.provider}/${entry.model}`
2979
+ if (used.has(key)) continue
2980
+ used.add(key)
2981
+ ordered.push(entry)
2982
+ }
2983
+ for (const entry of failing) {
2984
+ const key = `${entry.provider}/${entry.model}`
2985
+ if (used.has(key)) continue
2986
+ used.add(key)
2987
+ ordered.push(entry)
2988
+ }
2989
+ for (const pref of PREFERRED_DEFAULT_MODELS) {
2990
+ const key = `${pref.provider}/${pref.model}`
2991
+ if (used.has(key)) continue
2992
+ const idx = ordered.findIndex((e) => e.provider === pref.provider && e.model === pref.model)
2993
+ if (idx >= 0) {
2994
+ const [picked] = ordered.splice(idx, 1)
2995
+ used.add(key)
2996
+ ordered.push(picked)
2997
+ }
2998
+ }
2999
+
2422
3000
  return {
2423
3001
  name: DEFAULT_ROUTER_SETTINGS.activeSet,
2424
- models: ordered.slice(0, maxModels).map((entry, index) => ({
3002
+ models: ordered.slice(0, Math.max(1, maxModels)).map((entry, index) => ({
2425
3003
  provider: entry.provider,
2426
3004
  model: entry.model,
2427
3005
  priority: index + 1,
@@ -2451,7 +3029,113 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
2451
3029
  })
2452
3030
  }
2453
3031
 
2454
- function ensureRouterConfigForDaemon(config, skipSave = false) {
3032
+ /**
3033
+ * 📖 createDefaultProbeFn — used by buildDefaultRouterSet to find models
3034
+ * 📖 that actually work with the user's API keys. Returns an async probe
3035
+ * 📖 `(entry) => { ok, latencyMs, code }` that posts a 1-token chat-
3036
+ * 📖 completion to the provider's URL and treats 2xx as "working".
3037
+ *
3038
+ * 📖 This is what powers the M5 "default to working models" promise: a new
3039
+ * 📖 user with a half-broken key set still gets a default router set made
3040
+ * 📖 of models that come back 200, instead of a list of pinned NVIDIA
3041
+ * 📖 models that all 401.
3042
+ *
3043
+ * 📖 The probe is best-effort: it never throws, it just times out after
3044
+ * 📖 `probeTimeoutMs` and the caller treats timeouts as a failed probe.
3045
+ *
3046
+ * @returns {(entry: { provider: string, model: string }) => Promise<{ ok: boolean, code: string|number, latencyMs: number }>}
3047
+ */
3048
+ function createDefaultProbeFn(apiKeys) {
3049
+ return async (entry) => {
3050
+ const { provider, model } = entry
3051
+ if (!isRouteableProvider(provider)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
3052
+ const url = resolveProviderUrl(provider)
3053
+ if (!url) return { ok: false, code: 'NO_URL', latencyMs: 0 }
3054
+ const apiKey = getApiKey({ apiKeys: apiKeys || {} }, provider) || ''
3055
+ if (!apiKey) return { ok: false, code: 'NO_KEY', latencyMs: 0 }
3056
+ const apiModelId = provider === 'zai' ? model.replace(/^zai\//, '') : model
3057
+ const probeBody = buildChatCompletionPingBody(apiModelId, {}, {
3058
+ disableThinking: !disabledThinkingUnsupportedProviders.has(provider),
3059
+ })
3060
+ const headers = { 'Content-Type': 'application/json' }
3061
+ if (provider === 'cloudflare') {
3062
+ // 📖 Cloudflare uses account_id in the URL — resolveCloudflareUrl is
3063
+ // 📖 already imported. We just need the standard Bearer header.
3064
+ headers.Authorization = `Bearer ${apiKey}`
3065
+ } else if (provider === 'replicate') {
3066
+ headers.Authorization = `Token ${apiKey}`
3067
+ headers.Prefer = 'wait=4'
3068
+ } else {
3069
+ headers.Authorization = `Bearer ${apiKey}`
3070
+ if (provider === 'openrouter') {
3071
+ headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
3072
+ headers['X-Title'] = 'free-coding-models'
3073
+ }
3074
+ }
3075
+ const started = Date.now()
3076
+ try {
3077
+ const controller = new AbortController()
3078
+ const timer = setTimeout(() => controller.abort(), 1500)
3079
+ const resp = await fetch(resolveProviderUrl(provider) || url, {
3080
+ method: 'POST',
3081
+ headers,
3082
+ body: JSON.stringify(probeBody),
3083
+ signal: controller.signal,
3084
+ })
3085
+ clearTimeout(timer)
3086
+ const latencyMs = Date.now() - started
3087
+ const code = resp.status
3088
+ return { ok: resp.ok, code, latencyMs }
3089
+ } catch (err) {
3090
+ return { ok: false, code: err?.name === 'AbortError' ? 'TIMEOUT' : 'ERR', latencyMs: Date.now() - started, error: err?.message }
3091
+ }
3092
+ }
3093
+ }
3094
+
3095
+ function buildDefaultRouterSetSync(config = {}, maxModels = 5) {
3096
+ // 📖 Synchronous fallback used when async probing isn't available (e.g.
3097
+ // 📖 routerConfig() getter, which is on the hot path). Falls back to the
3098
+ // 📖 static tier-based ordering. The async probed version is the one
3099
+ // 📖 used at first daemon start; this sync version exists so the router
3100
+ // 📖 still works even before the probe completes.
3101
+ const keyedProviders = new Set(Object.entries(config.apiKeys || {})
3102
+ .filter(([, value]) => (Array.isArray(value) ? value.length > 0 : typeof value === 'string' && value.trim()))
3103
+ .map(([provider]) => provider))
3104
+ const entries = []
3105
+ for (const [providerKey, source] of Object.entries(sources)) {
3106
+ if (!isRouteableProvider(providerKey)) continue
3107
+ for (const [model, label, tier, sweScore, ctx] of source.models || []) {
3108
+ entries.push({ provider: providerKey, model, label, tier, sweScore, ctx, hasKey: keyedProviders.has(providerKey) })
3109
+ }
3110
+ }
3111
+ const preferred = entries.some((e) => e.hasKey) ? entries.filter((e) => e.hasKey) : entries
3112
+ const pinned = []
3113
+ const allRemaining = [...entries]
3114
+ for (const pref of PREFERRED_DEFAULT_MODELS) {
3115
+ const idx = allRemaining.findIndex((e) => e.provider === pref.provider && e.model === pref.model)
3116
+ if (idx >= 0) pinned.push(allRemaining.splice(idx, 1)[0])
3117
+ }
3118
+ const remaining = preferred.filter((e) => !pinned.some((p) => p.provider === e.provider && p.model === e.model))
3119
+ remaining.sort((a, b) => {
3120
+ const tierCmp = TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)
3121
+ if (tierCmp !== 0) return tierCmp
3122
+ const sweA = Number.parseFloat(a.sweScore) || 0
3123
+ const sweB = Number.parseFloat(b.sweScore) || 0
3124
+ return sweB - sweA
3125
+ })
3126
+ const ordered = [...pinned, ...remaining]
3127
+ return {
3128
+ name: DEFAULT_ROUTER_SETTINGS.activeSet,
3129
+ models: ordered.slice(0, maxModels).map((entry, index) => ({
3130
+ provider: entry.provider,
3131
+ model: entry.model,
3132
+ priority: index + 1,
3133
+ })),
3134
+ created: nowIso(),
3135
+ }
3136
+ }
3137
+
3138
+ async function ensureRouterConfigForDaemon(config, skipSave = false) {
2455
3139
  // 📖 Preserve existing named sets (e.g., created by sync-set) to avoid overwriting
2456
3140
  // 📖 user-created configurations. Only rebuild from favorites/defaults when no
2457
3141
  // 📖 sets exist at all (fresh install).
@@ -2465,7 +3149,17 @@ function ensureRouterConfigForDaemon(config, skipSave = false) {
2465
3149
  activeSet = { name: existingActiveSet, models: existingSetData.models, created: existingSetData.created }
2466
3150
  } else {
2467
3151
  const favSet = buildRouterSetFromFavorites(config)
2468
- activeSet = favSet || buildDefaultRouterSet(config)
3152
+ // 📖 The async probed version of buildDefaultRouterSet is preferred;
3153
+ // 📖 on failure it falls back to the sync static ordering.
3154
+ try {
3155
+ activeSet = favSet || await buildDefaultRouterSet(config, 5, {
3156
+ probeFn: createDefaultProbeFn(config.apiKeys || {}),
3157
+ probeTimeoutMs: 1500,
3158
+ probeBudget: 24,
3159
+ })
3160
+ } catch {
3161
+ activeSet = favSet || buildDefaultRouterSetSync(config)
3162
+ }
2469
3163
  }
2470
3164
  config.router = normalizeRouterConfig({
2471
3165
  ...DEFAULT_ROUTER_SETTINGS,
@@ -2550,7 +3244,7 @@ async function listenWithFallback(server, preferredPort, logger, host = '127.0.0
2550
3244
 
2551
3245
  export async function runRouterDaemon() {
2552
3246
  const config = loadConfig()
2553
- const router = ensureRouterConfigForDaemon(config)
3247
+ const router = await ensureRouterConfigForDaemon(config)
2554
3248
  const logger = new RouterLogger(ROUTER_LOG_PATH, router.logLevel)
2555
3249
  const runtime = new RouterRuntime({ config, port: router.port, logger })
2556
3250
  runtime.installProcessSafety()
@@ -2579,6 +3273,34 @@ export async function runRouterDaemon() {
2579
3273
  runtime.tokenFlushTimer = setInterval(() => runtime.tokenTracker.flush(), TOKEN_FLUSH_INTERVAL_MS)
2580
3274
  void runtime.runProbeBurst()
2581
3275
  runtime.scheduleProbeLoop()
3276
+ // 📖 Auto-heal: wait for the first probe burst to populate health data,
3277
+ // 📖 then swap any broken models (AUTH_ERROR / STALE) for working
3278
+ // 📖 alternatives. This is the M6 promise: the Playground and Router
3279
+ // 📖 Dashboard both start with a usable set by default. The user can
3280
+ // 📖 disable auto-heal by editing the active set (the first manual edit
3281
+ // 📖 sets `router.userCustomized = true` and auto-heal becomes a no-op).
3282
+ // 📖 We run two passes: one after the initial probe (8s) and another
3283
+ // 📖 after the replacement models have been probed too (24s). This
3284
+ // 📖 handles the case where the first replacement is itself broken
3285
+ // 📖 (e.g. a different model of a provider whose key is dead).
3286
+ void (async () => {
3287
+ try {
3288
+ await new Promise((resolve) => setTimeout(resolve, 8000))
3289
+ const first = await runtime.autoHealActiveSet()
3290
+ if (first?.ok && first.replaced > 0) {
3291
+ // 📖 Re-probe the new set, wait for the probes to land, then
3292
+ // 📖 check again in case the first replacement was also broken.
3293
+ void runtime.runProbeBurst()
3294
+ await new Promise((resolve) => setTimeout(resolve, 16000))
3295
+ const second = await runtime.autoHealActiveSet()
3296
+ if (second?.ok && second.replaced > 0) {
3297
+ void runtime.runProbeBurst()
3298
+ }
3299
+ }
3300
+ } catch (err) {
3301
+ runtime.logger.warn('autoHeal failed', { error: err?.message || String(err) })
3302
+ }
3303
+ })()
2582
3304
  return runtime
2583
3305
  }
2584
3306