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.
@@ -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, existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
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 = 60000
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
- // 📖 Always rebuild from favorites or defaults no more manual set management
1815
- const favSet = buildRouterSetFromFavorites(config)
1816
- const activeSet = favSet || buildDefaultRouterSet(config)
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('listening', onListening)
2199
+ server.off('error', onError)
1865
2200
  reject(error)
1866
2201
  }
1867
2202
  const onListening = () => {
1868
- server.off('error', onError)
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, '127.0.0.1')
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 port = await listenWithFallback(server, router.port, logger)
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',