free-coding-models 0.5.10 → 0.5.14

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.
@@ -394,7 +394,17 @@ function getWebConfigPayload(runtime) {
394
394
  cliOnly: src.cliOnly || false,
395
395
  }
396
396
  }
397
- return { providers, totalModels: MODELS.length }
397
+ const router = runtime.routerConfig()
398
+ return {
399
+ providers,
400
+ totalModels: MODELS.length,
401
+ prePrompt: {
402
+ enabled: router.prePrompt?.enabled === true,
403
+ text: router.prePrompt?.text || '',
404
+ isDefault: router.prePrompt?.text === DEFAULT_ROUTER_SETTINGS.prePrompt.text
405
+ && router.prePrompt?.enabled === DEFAULT_ROUTER_SETTINGS.prePrompt.enabled,
406
+ },
407
+ }
398
408
  }
399
409
 
400
410
  const WEB_DIST_DIR = resolvePath(__dirname, '..', '..', 'web', 'dist')
@@ -616,6 +626,54 @@ function readJsonBody(req) {
616
626
  })
617
627
  }
618
628
 
629
+ /**
630
+ * 📖 Inject the configured router pre-prompt as the first `system` message
631
+ * 📖 of the request, ahead of any user-provided messages. The pre-prompt is
632
+ * 📖 always prepended (not appended) so it takes precedence over the
633
+ * 📖 per-conversation tone; user `system` messages after the pre-prompt can
634
+ * 📖 still override specific instructions.
635
+ *
636
+ * 📖 If the pre-prompt is disabled or empty, the messages array is returned
637
+ * 📖 as-is. The function is pure: it never mutates the input.
638
+ *
639
+ * @param {unknown} messages
640
+ * @param {{ enabled?: boolean, text?: string }|null|undefined} prePrompt
641
+ * @returns {Array}
642
+ */
643
+ export function injectPrePrompt(messages, prePrompt) {
644
+ if (!Array.isArray(messages)) return messages
645
+ if (!prePrompt || prePrompt.enabled !== true) return messages
646
+ const text = typeof prePrompt.text === 'string' ? prePrompt.text.trim() : ''
647
+ if (!text) return messages
648
+ // 📖 Skip injection if the very first message is already an exact match —
649
+ // 📖 prevents duplicate system messages when the client retries a request
650
+ // 📖 or the Playground already sent the pre-prompt itself.
651
+ const first = messages[0]
652
+ if (first && first.role === 'system' && typeof first.content === 'string' && first.content.trim() === text) {
653
+ return messages
654
+ }
655
+ return [{ role: 'system', content: text }, ...messages]
656
+ }
657
+
658
+ /**
659
+ * 📖 Apply the pre-prompt to a chat-completion body. Returns a new body so
660
+ * 📖 we never mutate the client's payload. Used by both the streaming and
661
+ * 📖 non-streaming proxy paths.
662
+ *
663
+ * @param {Record<string, unknown>|null|undefined} body
664
+ * @param {{ enabled?: boolean, text?: string }|null|undefined} prePrompt
665
+ * @returns {Record<string, unknown>}
666
+ */
667
+ export function applyPrePromptToBody(body, prePrompt) {
668
+ const safeBody = (body && typeof body === 'object' && !Array.isArray(body)) ? body : {}
669
+ // 📖 If the body is missing `messages`, start with an empty array so
670
+ // 📖 downstream code that always expects `messages` does not have to
671
+ // 📖 special-case the pre-prompt path.
672
+ const baseMessages = Array.isArray(safeBody.messages) ? safeBody.messages : []
673
+ const messages = injectPrePrompt(baseMessages, prePrompt)
674
+ return { ...safeBody, messages }
675
+ }
676
+
619
677
  class RouterLogger {
620
678
  constructor(logPath, level = 'info') {
621
679
  this.logPath = logPath
@@ -878,7 +936,11 @@ class RouterRuntime {
878
936
  routerConfig() {
879
937
  const normalized = normalizeRouterConfig(this.config.router)
880
938
  if (normalized) return normalized
881
- 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)
882
944
  return normalizeRouterConfig({
883
945
  ...DEFAULT_ROUTER_SETTINGS,
884
946
  enabled: true,
@@ -893,6 +955,22 @@ class RouterRuntime {
893
955
  this.refreshRouteState()
894
956
  }
895
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
+
896
974
  saveRouterConfig() {
897
975
  if (this.persistConfig === false) return { success: true, backupCreated: false }
898
976
  const result = saveConfig(this.config)
@@ -904,7 +982,7 @@ class RouterRuntime {
904
982
  try {
905
983
  const nextConfig = loadConfig()
906
984
  // 📖 Always rebuild the router set from favorites so UI toggles apply dynamically
907
- ensureRouterConfigForDaemon(nextConfig, true)
985
+ void ensureRouterConfigForDaemon(nextConfig, true)
908
986
  this.config = nextConfig
909
987
  this.refreshRouteState()
910
988
  this.scheduleProbeLoop()
@@ -1274,6 +1352,20 @@ class RouterRuntime {
1274
1352
  setCount: Object.keys(router.sets || {}).length,
1275
1353
  uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
1276
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,
1277
1369
  inFlight: this.inFlight,
1278
1370
  shuttingDown: this.shuttingDown,
1279
1371
  probeMode: router.probeMode,
@@ -1368,6 +1460,233 @@ class RouterRuntime {
1368
1460
  })))
1369
1461
  }
1370
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
+
1371
1690
  scheduleProbeLoop() {
1372
1691
  if (this.probeTimer) clearInterval(this.probeTimer)
1373
1692
  for (const timeout of this.probeTimeouts) clearTimeout(timeout)
@@ -1547,8 +1866,12 @@ class RouterRuntime {
1547
1866
  const controller = new AbortController()
1548
1867
  const timeout = setTimeout(() => controller.abort(), this.routerConfig().failover.requestTimeoutMs)
1549
1868
  const started = performance.now()
1869
+ // 📖 Pre-prompt is injected server-side so every client (OpenAI SDK,
1870
+ // 📖 curl, custom Playground) gets the FCM persona without any client
1871
+ // 📖 change. Non-streaming path.
1872
+ const bodyWithPrePrompt = applyPrePromptToBody(body, this.routerConfig().prePrompt)
1550
1873
  const upstreamBody = {
1551
- ...body,
1874
+ ...bodyWithPrePrompt,
1552
1875
  model: getApiModelId(candidate.provider, candidate.model),
1553
1876
  stream: false,
1554
1877
  }
@@ -1681,8 +2004,12 @@ class RouterRuntime {
1681
2004
  }
1682
2005
  const controller = new AbortController()
1683
2006
  const started = performance.now()
2007
+ // 📖 Pre-prompt is injected server-side so every client (OpenAI SDK,
2008
+ // 📖 curl, custom Playground) gets the FCM persona without any client
2009
+ // 📖 change. Streaming path.
2010
+ const bodyWithPrePrompt = applyPrePromptToBody(body, this.routerConfig().prePrompt)
1684
2011
  const upstreamBody = {
1685
- ...body,
2012
+ ...bodyWithPrePrompt,
1686
2013
  model: getApiModelId(candidate.provider, candidate.model),
1687
2014
  stream: true,
1688
2015
  }
@@ -1838,6 +2165,9 @@ class RouterRuntime {
1838
2165
  const router = this.routerConfig()
1839
2166
  const setNameMatch = url.pathname.match(/^\/sets\/([^/]+)$/)
1840
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$/)
1841
2171
 
1842
2172
  if (req.method === 'GET' && url.pathname === '/sets') {
1843
2173
  sendJson(res, 200, { activeSet: router.activeSet, sets: router.sets })
@@ -1864,6 +2194,7 @@ class RouterRuntime {
1864
2194
  })
1865
2195
  this.setRouterConfig(normalized)
1866
2196
  this.saveRouterConfig()
2197
+ this.markSetCustomized()
1867
2198
  this.broadcast('set_change', { old_set: router.activeSet, new_set: normalized.activeSet })
1868
2199
  sendJson(res, 201, { set: normalized.sets[normalized.activeSet] || normalized.sets[name], router: normalized })
1869
2200
  return
@@ -1877,6 +2208,7 @@ class RouterRuntime {
1877
2208
  }
1878
2209
  this.setRouterConfig({ ...router, activeSet: name })
1879
2210
  this.saveRouterConfig()
2211
+ this.markSetCustomized()
1880
2212
  this.broadcast('set_change', { old_set: router.activeSet, new_set: name })
1881
2213
  void this.runProbeBurst()
1882
2214
  sendJson(res, 200, { activeSet: name })
@@ -1903,6 +2235,7 @@ class RouterRuntime {
1903
2235
  const normalized = normalizeRouterConfig({ ...router, activeSet: nextActiveSet, sets: nextSets })
1904
2236
  this.setRouterConfig(normalized)
1905
2237
  this.saveRouterConfig()
2238
+ this.markSetCustomized()
1906
2239
  sendJson(res, 200, { set: normalized.sets[nextName], router: normalized })
1907
2240
  return
1908
2241
  }
@@ -1918,13 +2251,189 @@ class RouterRuntime {
1918
2251
  const nextActiveSet = router.activeSet === name ? (Object.keys(nextSets)[0] || DEFAULT_ROUTER_SETTINGS.activeSet) : router.activeSet
1919
2252
  this.setRouterConfig({ ...router, activeSet: nextActiveSet, sets: nextSets })
1920
2253
  this.saveRouterConfig()
2254
+ this.markSetCustomized()
1921
2255
  sendJson(res, 200, { deleted: name, activeSet: this.routerConfig().activeSet })
1922
2256
  return
1923
2257
  }
1924
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
+
1925
2389
  sendError(res, 404, 'Not found', 'invalid_request_error', 'not_found', requestId)
1926
2390
  }
1927
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
+
1928
2437
  async handleProbeModeRequest(req, res, requestId) {
1929
2438
  const body = await readJsonBody(req)
1930
2439
  const nextProbeMode = typeof body.probeMode === 'string'
@@ -2013,6 +2522,12 @@ class RouterRuntime {
2013
2522
  return
2014
2523
  }
2015
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
+ }
2016
2531
  await this.handleSetsRequest(req, res, url, requestId)
2017
2532
  return
2018
2533
  }
@@ -2022,6 +2537,32 @@ class RouterRuntime {
2022
2537
  sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
2023
2538
  return
2024
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
+ }
2025
2566
  if (req.method === 'GET' && url.pathname === '/api/state') {
2026
2567
  sendJson(res, 200, getWebStatePayload(this), { 'x-request-id': requestId })
2027
2568
  return
@@ -2030,6 +2571,50 @@ class RouterRuntime {
2030
2571
  sendJson(res, 200, getWebConfigPayload(this), { 'x-request-id': requestId })
2031
2572
  return
2032
2573
  }
2574
+ if (url.pathname === '/api/router/preprompt') {
2575
+ // 📖 Pre-prompt lives in `~/.free-coding-models.json` under
2576
+ // 📖 `router.prePrompt`. The GET returns the effective value so the
2577
+ // 📖 Playground can render it next to the input box, and the PUT
2578
+ // 📖 updates the persisted config and triggers a hot reload so the
2579
+ // 📖 next proxied request uses the new pre-prompt without restart.
2580
+ if (req.method === 'GET') {
2581
+ const router = this.routerConfig()
2582
+ const fallback = DEFAULT_ROUTER_SETTINGS.prePrompt
2583
+ const isDefault = router.prePrompt?.text === fallback.text && router.prePrompt?.enabled === fallback.enabled
2584
+ sendJson(res, 200, {
2585
+ enabled: router.prePrompt?.enabled === true,
2586
+ text: router.prePrompt?.text || '',
2587
+ isDefault,
2588
+ defaultText: fallback.text,
2589
+ }, { 'x-request-id': requestId })
2590
+ return
2591
+ }
2592
+ if (req.method === 'PUT') {
2593
+ if (!isSameOriginOrLocal(req)) {
2594
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2595
+ return
2596
+ }
2597
+ const body = await readJsonBody(req)
2598
+ const nextEnabled = body?.enabled === true
2599
+ const nextText = typeof body?.text === 'string' ? body.text.slice(0, 4000) : ''
2600
+ const nextRouter = {
2601
+ ...this.routerConfig(),
2602
+ prePrompt: { enabled: nextEnabled, text: nextText },
2603
+ }
2604
+ this.setRouterConfig(nextRouter)
2605
+ this.saveRouterConfig()
2606
+ this.broadcast('config', { activeSet: this.routerConfig().activeSet, prePrompt: this.routerConfig().prePrompt })
2607
+ sendJson(res, 200, {
2608
+ ok: true,
2609
+ enabled: nextEnabled,
2610
+ text: nextText,
2611
+ isDefault: nextText === DEFAULT_ROUTER_SETTINGS.prePrompt.text && nextEnabled === DEFAULT_ROUTER_SETTINGS.prePrompt.enabled,
2612
+ }, { 'x-request-id': requestId })
2613
+ return
2614
+ }
2615
+ sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['GET', 'PUT'] })
2616
+ return
2617
+ }
2033
2618
  if (req.method === 'GET' && url.pathname === '/api/events') {
2034
2619
  if (this.sseClients.size >= MAX_SSE_CLIENTS) {
2035
2620
  sendError(res, 503, 'Too many dashboard clients', 'service_unavailable', 'too_many_sse_clients', requestId)
@@ -2263,17 +2848,48 @@ class RouterRuntime {
2263
2848
  }
2264
2849
  }
2265
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.
2266
2854
  const PREFERRED_DEFAULT_MODELS = [
2267
- { provider: 'nvidia', model: 'minimaxai/minimax-m2.7' },
2268
- { provider: 'nvidia', model: 'z-ai/glm-5.1' },
2269
- { provider: 'nvidia', model: 'deepseek-ai/deepseek-v4-flash' },
2270
- { 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' },
2271
2863
  ]
2272
2864
 
2273
- 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
+
2274
2889
  const keyedProviders = new Set(Object.entries(config.apiKeys || {})
2275
2890
  .filter(([, value]) => (Array.isArray(value) ? value.length > 0 : typeof value === 'string' && value.trim()))
2276
2891
  .map(([provider]) => provider))
2892
+
2277
2893
  const entries = []
2278
2894
  for (const [providerKey, source] of Object.entries(sources)) {
2279
2895
  if (!isRouteableProvider(providerKey)) continue
@@ -2289,29 +2905,101 @@ export function buildDefaultRouterSet(config = {}, maxModels = 5) {
2289
2905
  })
2290
2906
  }
2291
2907
  }
2292
- const preferred = entries.some((entry) => entry.hasKey)
2293
- ? entries.filter((entry) => entry.hasKey)
2294
- : entries
2295
- const pinned = []
2296
- const allRemaining = [...entries]
2297
- for (const pref of PREFERRED_DEFAULT_MODELS) {
2298
- const idx = allRemaining.findIndex((e) => e.provider === pref.provider && e.model === pref.model)
2299
- if (idx >= 0) {
2300
- pinned.push(allRemaining.splice(idx, 1)[0])
2301
- }
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
2302
2913
  }
2303
- const remaining = preferred.filter((e) => !pinned.some((p) => p.provider === e.provider && p.model === e.model))
2304
- remaining.sort((a, b) => {
2305
- 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)
2306
2920
  if (tierCmp !== 0) return tierCmp
2307
2921
  const sweA = Number.parseFloat(a.sweScore) || 0
2308
2922
  const sweB = Number.parseFloat(b.sweScore) || 0
2309
2923
  return sweB - sweA
2310
- })
2311
- 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
+
2312
3000
  return {
2313
3001
  name: DEFAULT_ROUTER_SETTINGS.activeSet,
2314
- models: ordered.slice(0, maxModels).map((entry, index) => ({
3002
+ models: ordered.slice(0, Math.max(1, maxModels)).map((entry, index) => ({
2315
3003
  provider: entry.provider,
2316
3004
  model: entry.model,
2317
3005
  priority: index + 1,
@@ -2341,7 +3029,113 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
2341
3029
  })
2342
3030
  }
2343
3031
 
2344
- 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) {
2345
3139
  // 📖 Preserve existing named sets (e.g., created by sync-set) to avoid overwriting
2346
3140
  // 📖 user-created configurations. Only rebuild from favorites/defaults when no
2347
3141
  // 📖 sets exist at all (fresh install).
@@ -2355,7 +3149,17 @@ function ensureRouterConfigForDaemon(config, skipSave = false) {
2355
3149
  activeSet = { name: existingActiveSet, models: existingSetData.models, created: existingSetData.created }
2356
3150
  } else {
2357
3151
  const favSet = buildRouterSetFromFavorites(config)
2358
- 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
+ }
2359
3163
  }
2360
3164
  config.router = normalizeRouterConfig({
2361
3165
  ...DEFAULT_ROUTER_SETTINGS,
@@ -2440,7 +3244,7 @@ async function listenWithFallback(server, preferredPort, logger, host = '127.0.0
2440
3244
 
2441
3245
  export async function runRouterDaemon() {
2442
3246
  const config = loadConfig()
2443
- const router = ensureRouterConfigForDaemon(config)
3247
+ const router = await ensureRouterConfigForDaemon(config)
2444
3248
  const logger = new RouterLogger(ROUTER_LOG_PATH, router.logLevel)
2445
3249
  const runtime = new RouterRuntime({ config, port: router.port, logger })
2446
3250
  runtime.installProcessSafety()
@@ -2469,6 +3273,34 @@ export async function runRouterDaemon() {
2469
3273
  runtime.tokenFlushTimer = setInterval(() => runtime.tokenTracker.flush(), TOKEN_FLUSH_INTERVAL_MS)
2470
3274
  void runtime.runProbeBurst()
2471
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
+ })()
2472
3304
  return runtime
2473
3305
  }
2474
3306