free-coding-models 0.3.66 → 0.3.67
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/CHANGELOG.md +39 -9
- package/README.md +111 -12
- package/bin/free-coding-models.js +1 -9
- package/package.json +1 -1
- package/src/cli-help.js +2 -3
- package/src/router-daemon.js +351 -15
- package/web/dist/assets/{index-BKwbbLPp.js → index-DCwSuNgI.js} +1 -1
- package/web/dist/index.html +1 -1
package/src/router-daemon.js
CHANGED
|
@@ -30,17 +30,19 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { createServer } from 'node:http'
|
|
33
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
34
|
+
import { dirname, join, resolve as resolvePath } from 'node:path'
|
|
33
35
|
import { fork } from 'node:child_process'
|
|
34
36
|
import { randomUUID } from 'node:crypto'
|
|
35
|
-
import { appendFileSync,
|
|
37
|
+
import { appendFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
36
38
|
import { homedir } from 'node:os'
|
|
37
|
-
import { dirname, join } from 'node:path'
|
|
38
39
|
import { fileURLToPath } from 'node:url'
|
|
39
|
-
import { sources } from '../sources.js'
|
|
40
|
+
import { MODELS, sources } from '../sources.js'
|
|
40
41
|
import {
|
|
41
42
|
CONFIG_PATH,
|
|
42
43
|
DEFAULT_ROUTER_SETTINGS,
|
|
43
44
|
getApiKey,
|
|
45
|
+
isProviderEnabled,
|
|
44
46
|
loadConfig,
|
|
45
47
|
normalizeRouterConfig,
|
|
46
48
|
saveConfig,
|
|
@@ -76,7 +78,7 @@ const MAX_SSE_CLIENTS = 10
|
|
|
76
78
|
const MAX_CONCURRENT_REQUESTS = 50
|
|
77
79
|
const MAX_PROBE_WINDOW = 20
|
|
78
80
|
const TOKEN_FLUSH_INTERVAL_MS = 60000
|
|
79
|
-
const CONFIG_RELOAD_INTERVAL_MS =
|
|
81
|
+
const CONFIG_RELOAD_INTERVAL_MS = 10000
|
|
80
82
|
const STATS_RETENTION_DAYS = 90
|
|
81
83
|
const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
82
84
|
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503])
|
|
@@ -201,6 +203,200 @@ function isLikelyHtmlResponse(headers, text = '') {
|
|
|
201
203
|
return contentType.includes('text/html') || isLikelyHtmlText(text)
|
|
202
204
|
}
|
|
203
205
|
|
|
206
|
+
// ─── Web Dashboard Helpers ─────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function maskApiKey(key) {
|
|
209
|
+
if (!key || typeof key !== 'string') return ''
|
|
210
|
+
if (key.length <= 8) return '••••••••'
|
|
211
|
+
return '••••••••' + key.slice(-4)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 📖 Same-origin / loopback check for state-changing or secret-revealing
|
|
215
|
+
// 📖 endpoints. Blocks CSRF from malicious tabs and key exfiltration from
|
|
216
|
+
// 📖 cross-origin scripts. Plain CLI calls (curl/fetch without Origin) are
|
|
217
|
+
// 📖 allowed because they cannot be triggered by a browser context.
|
|
218
|
+
function isLoopbackHostname(hostname) {
|
|
219
|
+
if (!hostname) return false
|
|
220
|
+
const h = hostname.toLowerCase()
|
|
221
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1' || h.endsWith('.localhost')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isSameOriginOrLocal(req) {
|
|
225
|
+
const origin = req.headers.origin
|
|
226
|
+
const referer = req.headers.referer || req.headers.referrer
|
|
227
|
+
const candidates = []
|
|
228
|
+
if (typeof origin === 'string' && origin && origin !== 'null') candidates.push(origin)
|
|
229
|
+
else if (typeof referer === 'string' && referer) candidates.push(referer)
|
|
230
|
+
|
|
231
|
+
// 📖 No Origin/Referer → non-browser caller (curl, native app). Allow.
|
|
232
|
+
if (candidates.length === 0) return true
|
|
233
|
+
|
|
234
|
+
for (const c of candidates) {
|
|
235
|
+
try {
|
|
236
|
+
const parsed = new URL(c)
|
|
237
|
+
if (isLoopbackHostname(parsed.hostname)) return true
|
|
238
|
+
} catch {
|
|
239
|
+
return false
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const MIME_TYPES = {
|
|
246
|
+
'.html': 'text/html; charset=utf-8',
|
|
247
|
+
'.css': 'text/css; charset=utf-8',
|
|
248
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
249
|
+
'.json': 'application/json; charset=utf-8',
|
|
250
|
+
'.svg': 'image/svg+xml',
|
|
251
|
+
'.png': 'image/png',
|
|
252
|
+
'.ico': 'image/x-icon',
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getWebModelsPayload(runtime) {
|
|
256
|
+
// 📖 Hoist router + active set lookups out of the per-model loop so we
|
|
257
|
+
// 📖 don't re-resolve them ~200 times per request.
|
|
258
|
+
const router = runtime.routerConfig()
|
|
259
|
+
const activeSet = runtime.getSet(router.activeSet)
|
|
260
|
+
const inSetIndex = new Set(
|
|
261
|
+
(activeSet?.models || []).map((m) => `${m.provider}::${m.model}`),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
const payload = []
|
|
265
|
+
for (const [providerKey, source] of Object.entries(sources)) {
|
|
266
|
+
if (!Array.isArray(source.models)) continue
|
|
267
|
+
const hasApiKey = !!runtime.getApiKeyForProvider(providerKey)
|
|
268
|
+
for (const [modelId, label, tier, sweScore, ctx] of source.models) {
|
|
269
|
+
const key = modelKey(providerKey, modelId)
|
|
270
|
+
const probeWindow = runtime.probeWindows.get(key) || []
|
|
271
|
+
const pings = probeWindow.map((entry) => ({
|
|
272
|
+
ms: entry.latencyMs ?? null,
|
|
273
|
+
code: entry.code ?? (entry.ok ? '200' : 'ERR'),
|
|
274
|
+
}))
|
|
275
|
+
const msList = pings.map((p) => p.ms).filter((ms) => typeof ms === 'number' && ms > 0)
|
|
276
|
+
const avg = msList.length > 0
|
|
277
|
+
? msList.reduce((sum, ms) => sum + ms, 0) / msList.length
|
|
278
|
+
: null
|
|
279
|
+
const sorted = [...msList].sort((a, b) => a - b)
|
|
280
|
+
const p95 = sorted.length > 0 ? sorted[Math.floor(sorted.length * 0.95)] : null
|
|
281
|
+
const jitter = sorted.length > 1
|
|
282
|
+
? sorted.slice(1).reduce((sum, v, i) => sum + Math.abs(v - sorted[i]), 0) / (sorted.length - 1)
|
|
283
|
+
: null
|
|
284
|
+
const recentOk = pings.filter((p) => typeof p.ms === 'number' && String(p.code) === '200').length
|
|
285
|
+
const stability = pings.length > 0 ? recentOk / pings.length : null
|
|
286
|
+
const verdict = avg === null ? '—' : avg < 1000 ? 'Excellent' : avg < 2000 ? 'Good' : avg < 4000 ? 'Fair' : 'Poor'
|
|
287
|
+
const uptime = pings.length > 0 ? recentOk / pings.length : null
|
|
288
|
+
payload.push({
|
|
289
|
+
idx: payload.length + 1,
|
|
290
|
+
modelId,
|
|
291
|
+
label,
|
|
292
|
+
tier,
|
|
293
|
+
sweScore,
|
|
294
|
+
ctx,
|
|
295
|
+
providerKey,
|
|
296
|
+
origin: source.name || providerKey,
|
|
297
|
+
status: pings.length === 0 ? 'pending' : recentOk > 0 ? 'up' : 'down',
|
|
298
|
+
httpCode: pings.length > 0 ? pings[pings.length - 1].code : null,
|
|
299
|
+
cliOnly: source.cliOnly || false,
|
|
300
|
+
zenOnly: source.zenOnly || false,
|
|
301
|
+
avg: avg === null ? null : Math.round(avg),
|
|
302
|
+
verdict,
|
|
303
|
+
uptime,
|
|
304
|
+
p95,
|
|
305
|
+
jitter: jitter === null ? null : Math.round(jitter),
|
|
306
|
+
stability: stability === null ? null : Math.round(stability * 100) / 100,
|
|
307
|
+
latestPing: pings.length > 0 ? pings[pings.length - 1].ms : null,
|
|
308
|
+
latestCode: pings.length > 0 ? pings[pings.length - 1].code : null,
|
|
309
|
+
pingHistory: pings.slice(-20),
|
|
310
|
+
pingCount: pings.length,
|
|
311
|
+
hasApiKey,
|
|
312
|
+
inRouterSet: inSetIndex.has(`${providerKey}::${modelId}`),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return payload
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getWebConfigPayload(runtime) {
|
|
320
|
+
const providers = {}
|
|
321
|
+
for (const [key, src] of Object.entries(sources)) {
|
|
322
|
+
const rawKey = runtime.getApiKeyForProvider(key)
|
|
323
|
+
providers[key] = {
|
|
324
|
+
name: src.name,
|
|
325
|
+
hasKey: !!rawKey,
|
|
326
|
+
maskedKey: rawKey ? maskApiKey(rawKey) : null,
|
|
327
|
+
enabled: isProviderEnabled(runtime.config, key),
|
|
328
|
+
modelCount: src.models?.length || 0,
|
|
329
|
+
cliOnly: src.cliOnly || false,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { providers, totalModels: MODELS.length }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const WEB_DIST_DIR = resolvePath(__dirname, '..', 'web', 'dist')
|
|
336
|
+
|
|
337
|
+
function serveStaticFromDist(res, absPath) {
|
|
338
|
+
const ext = absPath.slice(absPath.lastIndexOf('.'))
|
|
339
|
+
const ct = MIME_TYPES[ext] || 'application/octet-stream'
|
|
340
|
+
res.writeHead(200, {
|
|
341
|
+
'Content-Type': ct,
|
|
342
|
+
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
|
|
343
|
+
'X-Content-Type-Options': 'nosniff',
|
|
344
|
+
})
|
|
345
|
+
res.end(readFileSync(absPath))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function serveSpaIndex(res) {
|
|
349
|
+
const indexPath = resolvePath(WEB_DIST_DIR, 'index.html')
|
|
350
|
+
if (!existsSync(indexPath)) {
|
|
351
|
+
res.writeHead(503, { 'Content-Type': 'text/plain' })
|
|
352
|
+
res.end('Web dashboard not built. Run: pnpm build')
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
res.writeHead(200, {
|
|
356
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
357
|
+
'Cache-Control': 'no-cache',
|
|
358
|
+
'X-Content-Type-Options': 'nosniff',
|
|
359
|
+
})
|
|
360
|
+
res.end(readFileSync(indexPath))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function serveWebStaticFile(res, pathname, requestId) {
|
|
364
|
+
// 📖 Resolve to an absolute path and verify it stays inside WEB_DIST_DIR.
|
|
365
|
+
// 📖 Without this, `pathname` like `/../../etc/passwd` escapes the dist root.
|
|
366
|
+
const requested = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '')
|
|
367
|
+
const candidate = resolvePath(WEB_DIST_DIR, requested)
|
|
368
|
+
if (candidate !== WEB_DIST_DIR && !candidate.startsWith(WEB_DIST_DIR + '/')) {
|
|
369
|
+
sendError(res, 403, 'Forbidden', 'invalid_request_error', 'path_traversal_blocked', requestId)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let stats
|
|
374
|
+
try {
|
|
375
|
+
stats = statSync(candidate)
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (err.code === 'ENOENT') {
|
|
378
|
+
// 📖 SPA fallback: unknown route → serve index.html so client-side routing wins.
|
|
379
|
+
serveSpaIndex(res)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
sendError(res, 500, 'Failed to read static file', 'server_error', 'static_file_read_failed', requestId)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (stats.isDirectory()) {
|
|
387
|
+
const dirIndex = resolvePath(candidate, 'index.html')
|
|
388
|
+
if (dirIndex.startsWith(WEB_DIST_DIR + '/') && existsSync(dirIndex)) {
|
|
389
|
+
serveStaticFromDist(res, dirIndex)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
// 📖 Directory without index.html → fall back to SPA root.
|
|
393
|
+
serveSpaIndex(res)
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
serveStaticFromDist(res, candidate)
|
|
398
|
+
}
|
|
399
|
+
|
|
204
400
|
function buildUpstreamMeta(response, text = '') {
|
|
205
401
|
// 📖 Keep quota diagnostics structural only: headers and retry timing are safe,
|
|
206
402
|
// 📖 while upstream response bodies stay out of logs and telemetry.
|
|
@@ -867,6 +1063,24 @@ class RouterRuntime {
|
|
|
867
1063
|
}))
|
|
868
1064
|
}
|
|
869
1065
|
|
|
1066
|
+
findBestModelForProviderInSources(providerKey) {
|
|
1067
|
+
const source = sources[providerKey]
|
|
1068
|
+
if (!source || !Array.isArray(source.models)) return null
|
|
1069
|
+
let best = null
|
|
1070
|
+
let bestTier = Infinity
|
|
1071
|
+
for (const modelEntry of source.models) {
|
|
1072
|
+
const [modelId, , tier] = modelEntry
|
|
1073
|
+
if (!modelId || !tier) continue
|
|
1074
|
+
const tierIdx = TIER_ORDER.indexOf(tier)
|
|
1075
|
+
if (tierIdx < 0) continue
|
|
1076
|
+
if (tierIdx < bestTier) {
|
|
1077
|
+
bestTier = tierIdx
|
|
1078
|
+
best = modelId
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return best
|
|
1082
|
+
}
|
|
1083
|
+
|
|
870
1084
|
addRequestLog(entry) {
|
|
871
1085
|
this.requestLog.unshift({ ...entry, at: nowIso() })
|
|
872
1086
|
while (this.requestLog.length > MAX_REQUEST_LOG) this.requestLog.pop()
|
|
@@ -1640,6 +1854,115 @@ class RouterRuntime {
|
|
|
1640
1854
|
await this.handleSetsRequest(req, res, url, requestId)
|
|
1641
1855
|
return
|
|
1642
1856
|
}
|
|
1857
|
+
|
|
1858
|
+
// ─── Web Dashboard API endpoints ───────────────────────────────────────
|
|
1859
|
+
if (req.method === 'GET' && url.pathname === '/api/models') {
|
|
1860
|
+
sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
|
|
1861
|
+
return
|
|
1862
|
+
}
|
|
1863
|
+
if (req.method === 'GET' && url.pathname === '/api/config') {
|
|
1864
|
+
sendJson(res, 200, getWebConfigPayload(this), { 'x-request-id': requestId })
|
|
1865
|
+
return
|
|
1866
|
+
}
|
|
1867
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
1868
|
+
if (this.sseClients.size >= MAX_SSE_CLIENTS) {
|
|
1869
|
+
sendError(res, 503, 'Too many dashboard clients', 'service_unavailable', 'too_many_sse_clients', requestId)
|
|
1870
|
+
return
|
|
1871
|
+
}
|
|
1872
|
+
res.writeHead(200, {
|
|
1873
|
+
'Content-Type': 'text/event-stream',
|
|
1874
|
+
'Cache-Control': 'no-cache',
|
|
1875
|
+
'Connection': 'keep-alive',
|
|
1876
|
+
'x-request-id': requestId,
|
|
1877
|
+
})
|
|
1878
|
+
res.write(`data: ${JSON.stringify(getWebModelsPayload(this))}\n\n`)
|
|
1879
|
+
this.sseClients.add(res)
|
|
1880
|
+
req.on('close', () => this.sseClients.delete(res))
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/key/')) {
|
|
1884
|
+
// 📖 Reveals raw API keys — same-origin only to prevent malicious sites
|
|
1885
|
+
// 📖 from exfiltrating provider credentials via XHR/fetch.
|
|
1886
|
+
if (!isSameOriginOrLocal(req)) {
|
|
1887
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
1888
|
+
return
|
|
1889
|
+
}
|
|
1890
|
+
const providerKey = decodeURIComponent(url.pathname.slice('/api/key/'.length))
|
|
1891
|
+
if (!providerKey || !sources[providerKey]) {
|
|
1892
|
+
sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
|
|
1893
|
+
return
|
|
1894
|
+
}
|
|
1895
|
+
const rawKey = this.getApiKeyForProvider(providerKey)
|
|
1896
|
+
sendJson(res, 200, { key: rawKey || null }, { 'x-request-id': requestId })
|
|
1897
|
+
return
|
|
1898
|
+
}
|
|
1899
|
+
if (req.method === 'POST' && url.pathname === '/api/settings') {
|
|
1900
|
+
// 📖 Writes API keys + provider toggles — same-origin only to block
|
|
1901
|
+
// 📖 CSRF-style writes from malicious browser tabs.
|
|
1902
|
+
if (!isSameOriginOrLocal(req)) {
|
|
1903
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
1904
|
+
return
|
|
1905
|
+
}
|
|
1906
|
+
const body = await readJsonBody(req)
|
|
1907
|
+
if (body.apiKeys) {
|
|
1908
|
+
for (const [key, value] of Object.entries(body.apiKeys)) {
|
|
1909
|
+
if (value) {
|
|
1910
|
+
if (!this.config.apiKeys) this.config.apiKeys = {}
|
|
1911
|
+
this.config.apiKeys[key] = value
|
|
1912
|
+
} else {
|
|
1913
|
+
if (this.config.apiKeys) delete this.config.apiKeys[key]
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
if (body.providers) {
|
|
1918
|
+
for (const [key, value] of Object.entries(body.providers)) {
|
|
1919
|
+
if (!this.config.providers) this.config.providers = {}
|
|
1920
|
+
if (!this.config.providers[key]) this.config.providers[key] = {}
|
|
1921
|
+
this.config.providers[key].enabled = value.enabled !== false
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
saveConfig(this.config)
|
|
1926
|
+
} catch (err) {
|
|
1927
|
+
sendError(res, 500, 'Failed to save config: ' + err.message, 'server_error', 'config_save_failed', requestId)
|
|
1928
|
+
return
|
|
1929
|
+
}
|
|
1930
|
+
if (body.apiKeys) {
|
|
1931
|
+
for (const pk of Object.keys(body.apiKeys)) {
|
|
1932
|
+
if (typeof pk !== 'string' || !pk) continue
|
|
1933
|
+
const newModel = this.findBestModelForProviderInSources(pk)
|
|
1934
|
+
if (!newModel) continue
|
|
1935
|
+
const router = this.routerConfig()
|
|
1936
|
+
if (!router.activeSet) continue
|
|
1937
|
+
const activeSetData = router.sets?.[router.activeSet]
|
|
1938
|
+
if (!activeSetData || activeSetData.models?.some((m) => m.provider === pk)) continue
|
|
1939
|
+
const nextModels = [
|
|
1940
|
+
...(activeSetData.models || []),
|
|
1941
|
+
{ provider: pk, model: newModel, priority: (activeSetData.models?.length || 0) + 1 },
|
|
1942
|
+
]
|
|
1943
|
+
this.setRouterConfig({
|
|
1944
|
+
...router,
|
|
1945
|
+
sets: { ...router.sets, [router.activeSet]: { ...activeSetData, models: nextModels } },
|
|
1946
|
+
})
|
|
1947
|
+
}
|
|
1948
|
+
void this.runProbeBurst()
|
|
1949
|
+
}
|
|
1950
|
+
sendJson(res, 200, { success: true }, { 'x-request-id': requestId })
|
|
1951
|
+
return
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// ─── Static file serving for web dashboard ────────────────────────────────
|
|
1955
|
+
if (req.method === 'GET' && (
|
|
1956
|
+
url.pathname === '/' || url.pathname === '/index.html' ||
|
|
1957
|
+
url.pathname === '/styles.css' || url.pathname === '/app.js' ||
|
|
1958
|
+
url.pathname.startsWith('/assets/') ||
|
|
1959
|
+
url.pathname.endsWith('.js') || url.pathname.endsWith('.css') ||
|
|
1960
|
+
url.pathname.endsWith('.svg') || url.pathname.endsWith('.png') ||
|
|
1961
|
+
url.pathname.endsWith('.ico')
|
|
1962
|
+
)) {
|
|
1963
|
+
serveWebStaticFile(res, url.pathname, requestId)
|
|
1964
|
+
return
|
|
1965
|
+
}
|
|
1643
1966
|
if (url.pathname === '/v1/chat/completions' || url.pathname.match(/^\/v1\/sets\/[^/]+\/chat\/completions$/)) {
|
|
1644
1967
|
if (req.method !== 'POST') {
|
|
1645
1968
|
sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['POST'] })
|
|
@@ -1811,9 +2134,21 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
|
|
|
1811
2134
|
}
|
|
1812
2135
|
|
|
1813
2136
|
function ensureRouterConfigForDaemon(config, skipSave = false) {
|
|
1814
|
-
// 📖
|
|
1815
|
-
|
|
1816
|
-
|
|
2137
|
+
// 📖 Preserve existing named sets (e.g., created by sync-set) to avoid overwriting
|
|
2138
|
+
// 📖 user-created configurations. Only rebuild from favorites/defaults when no
|
|
2139
|
+
// 📖 sets exist at all (fresh install).
|
|
2140
|
+
const existingSets = config.router?.sets || {}
|
|
2141
|
+
const existingActiveSet = config.router?.activeSet || DEFAULT_ROUTER_SETTINGS.activeSet
|
|
2142
|
+
const existingSetData = existingSets[existingActiveSet]
|
|
2143
|
+
const hasExistingNamedSet = existingSetData && Array.isArray(existingSetData.models) && existingSetData.models.length > 0
|
|
2144
|
+
|
|
2145
|
+
let activeSet
|
|
2146
|
+
if (hasExistingNamedSet) {
|
|
2147
|
+
activeSet = { name: existingActiveSet, models: existingSetData.models, created: existingSetData.created }
|
|
2148
|
+
} else {
|
|
2149
|
+
const favSet = buildRouterSetFromFavorites(config)
|
|
2150
|
+
activeSet = favSet || buildDefaultRouterSet(config)
|
|
2151
|
+
}
|
|
1817
2152
|
config.router = normalizeRouterConfig({
|
|
1818
2153
|
...DEFAULT_ROUTER_SETTINGS,
|
|
1819
2154
|
enabled: true,
|
|
@@ -1858,23 +2193,23 @@ function buildRouterSetFromFavorites(config) {
|
|
|
1858
2193
|
}
|
|
1859
2194
|
}
|
|
1860
2195
|
|
|
1861
|
-
function listenOnPort(server, port) {
|
|
2196
|
+
function listenOnPort(server, port, host = '127.0.0.1') {
|
|
1862
2197
|
return new Promise((resolve, reject) => {
|
|
1863
2198
|
const onError = (error) => {
|
|
1864
|
-
server.off('
|
|
2199
|
+
server.off('error', onError)
|
|
1865
2200
|
reject(error)
|
|
1866
2201
|
}
|
|
1867
2202
|
const onListening = () => {
|
|
1868
|
-
server.off('
|
|
2203
|
+
server.off('listening', onListening)
|
|
1869
2204
|
resolve(port)
|
|
1870
2205
|
}
|
|
1871
2206
|
server.once('error', onError)
|
|
1872
2207
|
server.once('listening', onListening)
|
|
1873
|
-
server.listen(port,
|
|
2208
|
+
server.listen(port, host)
|
|
1874
2209
|
})
|
|
1875
2210
|
}
|
|
1876
2211
|
|
|
1877
|
-
async function listenWithFallback(server, preferredPort, logger) {
|
|
2212
|
+
async function listenWithFallback(server, preferredPort, logger, host = '127.0.0.1') {
|
|
1878
2213
|
const { defaultPort, maxPort } = getRouterPortRange()
|
|
1879
2214
|
const start = Math.max(1, preferredPort || defaultPort)
|
|
1880
2215
|
const candidates = []
|
|
@@ -1885,7 +2220,7 @@ async function listenWithFallback(server, preferredPort, logger) {
|
|
|
1885
2220
|
let lastError = null
|
|
1886
2221
|
for (const port of candidates) {
|
|
1887
2222
|
try {
|
|
1888
|
-
await listenOnPort(server, port)
|
|
2223
|
+
await listenOnPort(server, port, host)
|
|
1889
2224
|
return port
|
|
1890
2225
|
} catch (error) {
|
|
1891
2226
|
lastError = error
|
|
@@ -1903,13 +2238,14 @@ export async function runRouterDaemon() {
|
|
|
1903
2238
|
runtime.installProcessSafety()
|
|
1904
2239
|
const server = createServer((req, res) => void runtime.handleHttp(req, res))
|
|
1905
2240
|
runtime.server = server
|
|
1906
|
-
const
|
|
2241
|
+
const host = process.env.FCM_HOST || '127.0.0.1'
|
|
2242
|
+
const port = await listenWithFallback(server, router.port, logger, host)
|
|
1907
2243
|
runtime.port = port
|
|
1908
2244
|
runtime.config.router.port = port
|
|
1909
2245
|
saveConfig(runtime.config)
|
|
1910
2246
|
try { writeFileSync(ROUTER_PID_PATH, String(process.pid), { mode: 0o600 }) } catch (error) { logger.warn('PID file write failed', { error: error.message }) }
|
|
1911
2247
|
try { writeFileSync(ROUTER_PORT_PATH, String(port), { mode: 0o600 }) } catch (error) { logger.warn('Port file write failed', { error: error.message }) }
|
|
1912
|
-
logger.info('Router daemon started', { pid: process.pid, port, activeSet: runtime.routerConfig().activeSet })
|
|
2248
|
+
logger.info('Router daemon started', { pid: process.pid, port, host, activeSet: runtime.routerConfig().activeSet })
|
|
1913
2249
|
void sendUsageTelemetry(runtime.config, {}, {
|
|
1914
2250
|
event: 'app_daemon_start',
|
|
1915
2251
|
mode: 'daemon',
|