free-coding-models 0.3.65 → 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',
@@ -304,6 +304,28 @@ export const TOOL_BOOTSTRAP_METADATA = {
304
304
  },
305
305
  },
306
306
  },
307
+ copilot: {
308
+ binary: 'copilot',
309
+ docsUrl: 'https://github.com/github/copilot',
310
+ install: {
311
+ default: {
312
+ shellCommand: 'npm install -g @github/copilot',
313
+ summary: 'Install GitHub Copilot CLI globally via npm.',
314
+ note: 'After installation, run `copilot` to authenticate with GitHub.',
315
+ },
316
+ },
317
+ },
318
+ forgecode: {
319
+ binary: 'forge',
320
+ docsUrl: 'https://forgecode.dev',
321
+ install: {
322
+ default: {
323
+ shellCommand: 'npm install -g forgecode',
324
+ summary: 'Install ForgeCode globally via npm.',
325
+ note: 'After installation, run `forge` to start. Use `forge provider login` to set up credentials.',
326
+ },
327
+ },
328
+ },
307
329
  }
308
330
 
309
331
  export function getToolBootstrapMeta(mode) {
@@ -20,6 +20,7 @@
20
20
  * 📖 Hermes: uses `hermes config set` CLI commands + `hermes gateway restart` before launching `hermes chat`
21
21
  * 📖 Continue: writes ~/.continue/config.yaml with provider: openai + apiBase
22
22
  * 📖 Cline: writes ~/.cline/globalState.json with openai-compatible provider config
23
+ * 📖 ForgeCode: writes [[providers]] TOML block into ~/.forge/.forge.toml + sets [session] defaults
23
24
  *
24
25
  * @functions
25
26
  * → `resolveLauncherModelId` — choose the provider-specific id for a launch
@@ -64,6 +65,19 @@ function ensureDir(filePath) {
64
65
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
65
66
  }
66
67
 
68
+ // 📖 Parse a context window string (e.g. "128k", "1M", "32k") to token count number.
69
+ function parseCtxToTokens(ctx) {
70
+ if (!ctx || typeof ctx !== 'string') return null
71
+ const match = ctx.match(/^\s*(\d+(?:\.\d+)?)\s*([kKmM]?)\s*$/)
72
+ if (!match) return null
73
+ const num = parseFloat(match[1])
74
+ const suffix = match[2].toLowerCase()
75
+ // 📖 LLM token counts use binary (1024), not decimal (1000).
76
+ if (suffix === 'k') return Math.round(num * 1024)
77
+ if (suffix === 'm') return Math.round(num * 1024 * 1024)
78
+ return Math.round(num)
79
+ }
80
+
67
81
  function getDefaultToolPaths(homeDir = homedir()) {
68
82
  return {
69
83
  aiderConfigPath: join(homeDir, '.aider.conf.yml'),
@@ -79,6 +93,7 @@ function getDefaultToolPaths(homeDir = homedir()) {
79
93
  hermesConfigPath: join(homeDir, '.hermes', 'config.yaml'),
80
94
  continueConfigPath: join(homeDir, '.continue', 'config.yaml'),
81
95
  clineConfigPath: join(homeDir, '.cline', 'globalState.json'),
96
+ forgeCodeConfigPath: join(homeDir, '.forge', '.forge.toml'),
82
97
  }
83
98
  }
84
99
 
@@ -504,6 +519,86 @@ function writeHermesConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()
504
519
  return { filePath: configPath, backupPath }
505
520
  }
506
521
 
522
+ // 📖 writeForgeCodeConfig — write a managed [[providers]] block into ~/.forge/.forge.toml.
523
+ // 📖 ForgeCode uses TOML config with [[providers]] entries for custom OpenAI-compatible endpoints.
524
+ // 📖 Strategy:
525
+ // 📖 1. Read the existing .forge.toml (if any)
526
+ // 📖 2. Strip any previous FCM-managed provider block (delimited by comments)
527
+ // 📖 3. Append a fresh [[providers]] block with the selected model's provider details
528
+ // 📖 4. Update or insert [session] defaults to auto-select the model on next `forge` launch
529
+ // 📖 The provider ID uses the `fcm-{providerKey}` namespace to avoid clobbering user-defined providers.
530
+ // 📖 The API key is referenced via an env var (FCM_{PROVIDER}_API_KEY) and also set in the process env.
531
+ function writeForgeCodeConfig(model, apiKey, baseUrl, providerKey, paths = getDefaultToolPaths()) {
532
+ const filePath = paths.forgeCodeConfigPath
533
+ const backupPath = backupIfExists(filePath)
534
+ const providerId = `fcm-${providerKey}`
535
+ const providerLabel = PROVIDER_METADATA[providerKey]?.label || sources[providerKey]?.name || providerKey
536
+ const secretEnvName = `FCM_${providerKey.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
537
+
538
+ // 📖 Ensure the API key is available in env for ForgeCode to pick up
539
+ process.env[secretEnvName] = apiKey
540
+
541
+ // 📖 Build the provider's chat completions URL
542
+ const completionsUrl = baseUrl
543
+ ? (baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`)
544
+ : ''
545
+
546
+ // 📖 Read existing TOML content (if any)
547
+ let content = ''
548
+ if (existsSync(filePath)) {
549
+ content = readFileSync(filePath, 'utf8')
550
+ }
551
+
552
+ // 📖 Remove any previous FCM-managed provider block (between marker comments)
553
+ const markerStart = `# >>> FCM managed provider: ${providerId}`
554
+ const markerEnd = `# <<< FCM managed provider: ${providerId}`
555
+ const markerRegex = new RegExp(
556
+ `\\n?${markerStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${markerEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`,
557
+ 'g'
558
+ )
559
+ content = content.replace(markerRegex, '\n')
560
+
561
+ // 📖 Build a fresh [[providers]] TOML block
562
+ const providerBlock = [
563
+ '',
564
+ markerStart,
565
+ '[[providers]]',
566
+ `id = "${providerId}"`,
567
+ `url = "${completionsUrl}"`,
568
+ `api_key_vars = "${secretEnvName}"`,
569
+ 'response_type = "OpenAI"',
570
+ 'auth_methods = ["api_key"]',
571
+ markerEnd,
572
+ ].join('\n')
573
+
574
+ content = content.trimEnd() + '\n' + providerBlock + '\n'
575
+
576
+ // 📖 Update or insert [session] defaults so ForgeCode auto-selects this model
577
+ const sessionProviderLine = `provider_id = "${providerId}"`
578
+ const sessionModelLine = `model_id = "${model.modelId}"`
579
+
580
+ if (/^\[session\]/m.test(content)) {
581
+ // 📖 Replace existing provider_id/model_id under [session]
582
+ if (/^provider_id\s*=/m.test(content)) {
583
+ content = content.replace(/^provider_id\s*=.*$/m, sessionProviderLine)
584
+ } else {
585
+ content = content.replace(/^\[session\]/m, `[session]\n${sessionProviderLine}`)
586
+ }
587
+ if (/^model_id\s*=/m.test(content)) {
588
+ content = content.replace(/^model_id\s*=.*$/m, sessionModelLine)
589
+ } else {
590
+ content = content.replace(/^\[session\]/m, `[session]\n${sessionModelLine}`)
591
+ }
592
+ } else {
593
+ // 📖 No [session] block — append one
594
+ content = content.trimEnd() + '\n\n[session]\n' + sessionProviderLine + '\n' + sessionModelLine + '\n'
595
+ }
596
+
597
+ ensureDir(filePath)
598
+ writeFileSync(filePath, content)
599
+ return { filePath, backupPath }
600
+ }
601
+
507
602
  // 📖 restartHermesGateway — restart the Hermes messaging gateway after config changes.
508
603
  // 📖 Non-blocking: if gateway is not running, this is a no-op.
509
604
  function restartHermesGateway() {
@@ -833,6 +928,45 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
833
928
  }
834
929
  }
835
930
 
931
+ if (mode === 'copilot') {
932
+ // 📖 copilot: set BYOK env vars so copilot uses the selected provider/model
933
+ const copilotModelId = resolveLauncherModelId(model)
934
+ env.COPILOT_PROVIDER_BASE_URL = baseUrl
935
+ env.COPILOT_MODEL = copilotModelId
936
+ if (apiKey) env.COPILOT_PROVIDER_API_KEY = apiKey
937
+
938
+ // 📖 Set context window limits from model data
939
+ const promptTokens = parseCtxToTokens(model.ctx)
940
+ if (promptTokens) env.COPILOT_PROVIDER_MAX_PROMPT_TOKENS = String(promptTokens)
941
+ // 📖 16k max output as a safety cap — most S+/S tier coding models
942
+ // 📖 support 16-32k output. copilot falls back to built-in model
943
+ // 📖 catalog defaults when a model ID is recognized.
944
+ env.COPILOT_PROVIDER_MAX_OUTPUT_TOKENS = '16384'
945
+
946
+ return {
947
+ command: 'copilot',
948
+ args: [],
949
+ env,
950
+ apiKey,
951
+ baseUrl,
952
+ meta,
953
+ configArtifacts: [],
954
+ }
955
+ }
956
+
957
+ if (mode === 'forgecode') {
958
+ const result = writeForgeCodeConfig(model, apiKey, baseUrl, model.providerKey, paths)
959
+ return {
960
+ command: 'forge',
961
+ args: [],
962
+ env,
963
+ apiKey,
964
+ baseUrl,
965
+ meta,
966
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
967
+ }
968
+ }
969
+
836
970
  return {
837
971
  blocked: true,
838
972
  exitCode: 1,
@@ -934,6 +1068,16 @@ export async function startExternalTool(mode, model, config) {
934
1068
  return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
935
1069
  }
936
1070
 
1071
+ if (mode === 'copilot') {
1072
+ console.log(chalk.dim(` 📖 Copilot CLI configured with model: ${model.modelId}`))
1073
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1074
+ }
1075
+
1076
+ if (mode === 'forgecode') {
1077
+ console.log(chalk.dim(` 📖 ForgeCode configured with model: ${model.modelId}`))
1078
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1079
+ }
1080
+
937
1081
  console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
938
1082
  return 1
939
1083
  }