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.
- package/README.md +3 -0
- package/changelog/v0.5.12.md +152 -0
- package/package.json +1 -1
- package/src/core/config.js +15 -0
- package/src/core/router-daemon.js +748 -26
- package/src/tui/render-helpers.js +35 -0
- package/src/tui/render-table.js +22 -7
- package/web/dist/assets/index-Ce_pr2YF.js +39 -0
- package/web/dist/assets/index-H9JWDRIh.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +116 -1
- package/web/src/components/dashboard/ModelTable.jsx +9 -0
- package/web/src/components/dashboard/ModelTable.module.css +13 -0
- package/web/src/components/router/RouterView.jsx +519 -35
- package/web/src/components/router/RouterView.module.css +355 -0
- package/web/dist/assets/index-DeEJErFO.js +0 -39
- package/web/dist/assets/index-Z24EXUEe.css +0 -1
|
@@ -936,7 +936,11 @@ class RouterRuntime {
|
|
|
936
936
|
routerConfig() {
|
|
937
937
|
const normalized = normalizeRouterConfig(this.config.router)
|
|
938
938
|
if (normalized) return normalized
|
|
939
|
-
|
|
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: '
|
|
2378
|
-
{ provider: '
|
|
2379
|
-
{ provider: '
|
|
2380
|
-
{ provider: 'nvidia',
|
|
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
|
-
|
|
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
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
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
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|