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.
- package/README.md +40 -0
- package/bin/free-coding-models.js +6 -1
- package/changelog/v0.5.11.md +56 -0
- package/changelog/v0.5.12.md +68 -0
- package/changelog/v0.5.13.md +57 -0
- package/package.json +1 -1
- package/src/core/config.js +59 -1
- package/src/core/playground.js +502 -0
- package/src/core/router-daemon.js +861 -29
- package/src/core/utils.js +7 -0
- package/src/tui/app.js +9 -1
- package/src/tui/cli-help.js +2 -0
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +22 -0
- package/src/tui/overlays.js +11 -0
- package/src/tui/render-helpers.js +35 -0
- package/src/tui/render-table.js +22 -7
- package/src/tui/theme.js +3 -0
- package/web/dist/assets/index-Bzqz7KbT.js +39 -0
- package/web/dist/assets/index-H9JWDRIh.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +203 -1
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/ModelTable.jsx +9 -0
- package/web/src/components/dashboard/ModelTable.module.css +13 -0
- package/web/src/components/layout/Header.jsx +2 -1
- package/web/src/components/palette/CommandPalette.jsx +1 -0
- package/web/src/components/playground/PlaygroundView.jsx +528 -0
- package/web/src/components/playground/PlaygroundView.module.css +413 -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-BrJgRhTA.css +0 -1
- package/web/dist/assets/index-BwLMt0bw.js +0 -39
|
@@ -394,7 +394,17 @@ function getWebConfigPayload(runtime) {
|
|
|
394
394
|
cliOnly: src.cliOnly || false,
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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: '
|
|
2268
|
-
{ provider: '
|
|
2269
|
-
{ provider: '
|
|
2270
|
-
{ 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' },
|
|
2271
2863
|
]
|
|
2272
2864
|
|
|
2273
|
-
|
|
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
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
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
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|