free-coding-models 0.3.9 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +112 -1134
- package/bin/free-coding-models.js +34 -188
- package/package.json +2 -3
- package/src/cli-help.js +0 -18
- package/src/config.js +17 -351
- package/src/endpoint-installer.js +26 -64
- package/src/favorites.js +0 -14
- package/src/key-handler.js +74 -641
- package/src/legacy-proxy-cleanup.js +432 -0
- package/src/openclaw.js +69 -108
- package/src/opencode-config.js +48 -0
- package/src/opencode.js +6 -248
- package/src/overlays.js +26 -550
- package/src/product-flags.js +14 -0
- package/src/render-helpers.js +2 -34
- package/src/render-table.js +14 -33
- package/src/testfcm.js +90 -43
- package/src/token-usage-reader.js +9 -38
- package/src/tool-launchers.js +235 -409
- package/src/tool-metadata.js +0 -7
- package/src/utils.js +8 -77
- package/bin/fcm-proxy-daemon.js +0 -242
- package/src/account-manager.js +0 -634
- package/src/anthropic-translator.js +0 -440
- package/src/daemon-manager.js +0 -527
- package/src/error-classifier.js +0 -154
- package/src/log-reader.js +0 -195
- package/src/opencode-sync.js +0 -200
- package/src/proxy-server.js +0 -1477
- package/src/proxy-sync.js +0 -565
- package/src/proxy-topology.js +0 -85
- package/src/request-transformer.js +0 -180
- package/src/responses-translator.js +0 -423
- package/src/token-stats.js +0 -320
package/src/tool-metadata.js
CHANGED
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
*
|
|
22
22
|
* @exports TOOL_METADATA, TOOL_MODE_ORDER, getToolMeta, getToolModeOrder
|
|
23
23
|
*/
|
|
24
|
-
|
|
25
24
|
export const TOOL_METADATA = {
|
|
26
25
|
opencode: { label: 'OpenCode CLI', emoji: '💻', flag: '--opencode' },
|
|
27
26
|
'opencode-desktop': { label: 'OpenCode Desktop', emoji: '🖥', flag: '--opencode-desktop' },
|
|
@@ -30,9 +29,6 @@ export const TOOL_METADATA = {
|
|
|
30
29
|
goose: { label: 'Goose', emoji: '🪿', flag: '--goose' },
|
|
31
30
|
pi: { label: 'Pi', emoji: 'π', flag: '--pi' },
|
|
32
31
|
aider: { label: 'Aider', emoji: '🛠', flag: '--aider' },
|
|
33
|
-
'claude-code': { label: 'Claude Code', emoji: '🧠', flag: '--claude-code' },
|
|
34
|
-
codex: { label: 'Codex CLI', emoji: '⌘', flag: '--codex' },
|
|
35
|
-
gemini: { label: 'Gemini CLI', emoji: '✦', flag: '--gemini' },
|
|
36
32
|
qwen: { label: 'Qwen Code', emoji: '🌊', flag: '--qwen' },
|
|
37
33
|
openhands: { label: 'OpenHands', emoji: '🤲', flag: '--openhands' },
|
|
38
34
|
amp: { label: 'Amp', emoji: '⚡', flag: '--amp' },
|
|
@@ -46,9 +42,6 @@ export const TOOL_MODE_ORDER = [
|
|
|
46
42
|
'goose',
|
|
47
43
|
'pi',
|
|
48
44
|
'aider',
|
|
49
|
-
'claude-code',
|
|
50
|
-
'codex',
|
|
51
|
-
'gemini',
|
|
52
45
|
'qwen',
|
|
53
46
|
'openhands',
|
|
54
47
|
'amp',
|
package/src/utils.js
CHANGED
|
@@ -388,14 +388,14 @@ export function findBestModel(results) {
|
|
|
388
388
|
// 📖 Argument types:
|
|
389
389
|
// - API key: first positional arg that does not look like a CLI flag (e.g., "nvapi-xxx")
|
|
390
390
|
// - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw,
|
|
391
|
-
// --aider, --crush, --goose, --
|
|
391
|
+
// --aider, --crush, --goose, --qwen,
|
|
392
392
|
// --openhands, --amp, --pi, --no-telemetry, --json, --help/-h (case-insensitive)
|
|
393
393
|
// - Value flag: --tier <letter> (the next non-flag arg is the tier value)
|
|
394
394
|
//
|
|
395
395
|
// 📖 Returns:
|
|
396
396
|
// { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode,
|
|
397
|
-
// aiderMode, crushMode, gooseMode,
|
|
398
|
-
//
|
|
397
|
+
// aiderMode, crushMode, gooseMode, qwenMode, openHandsMode, ampMode,
|
|
398
|
+
// piMode, noTelemetry, jsonMode, helpMode, tierFilter }
|
|
399
399
|
//
|
|
400
400
|
// 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
|
|
401
401
|
export function parseArgs(argv) {
|
|
@@ -403,17 +403,12 @@ export function parseArgs(argv) {
|
|
|
403
403
|
let apiKey = null
|
|
404
404
|
const flags = []
|
|
405
405
|
|
|
406
|
-
// 📖 Determine which arg indices are consumed by --tier
|
|
406
|
+
// 📖 Determine which arg indices are consumed by --tier so we skip them
|
|
407
407
|
const tierIdx = args.findIndex(a => a.toLowerCase() === '--tier')
|
|
408
408
|
const tierValueIdx = (tierIdx !== -1 && args[tierIdx + 1] && !args[tierIdx + 1].startsWith('--'))
|
|
409
409
|
? tierIdx + 1
|
|
410
410
|
: -1
|
|
411
411
|
|
|
412
|
-
const profileIdx = args.findIndex(a => a.toLowerCase() === '--profile')
|
|
413
|
-
const profileValueIdx = (profileIdx !== -1 && args[profileIdx + 1] && !args[profileIdx + 1].startsWith('--'))
|
|
414
|
-
? profileIdx + 1
|
|
415
|
-
: -1
|
|
416
|
-
|
|
417
412
|
// New value flags
|
|
418
413
|
const sortIdx = args.findIndex(a => a.toLowerCase() === '--sort')
|
|
419
414
|
const sortValueIdx = (sortIdx !== -1 && args[sortIdx + 1] && !args[sortIdx + 1].startsWith('--'))
|
|
@@ -433,7 +428,6 @@ export function parseArgs(argv) {
|
|
|
433
428
|
// 📖 Set of arg indices that are values for flags (not API keys)
|
|
434
429
|
const skipIndices = new Set()
|
|
435
430
|
if (tierValueIdx !== -1) skipIndices.add(tierValueIdx)
|
|
436
|
-
if (profileValueIdx !== -1) skipIndices.add(profileValueIdx)
|
|
437
431
|
if (sortValueIdx !== -1) skipIndices.add(sortValueIdx)
|
|
438
432
|
if (originValueIdx !== -1) skipIndices.add(originValueIdx)
|
|
439
433
|
if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
|
|
@@ -442,7 +436,7 @@ export function parseArgs(argv) {
|
|
|
442
436
|
if (arg.startsWith('--') || arg === '-h') {
|
|
443
437
|
flags.push(arg.toLowerCase())
|
|
444
438
|
} else if (skipIndices.has(i)) {
|
|
445
|
-
// 📖 Skip — this is a value for --tier
|
|
439
|
+
// 📖 Skip — this is a value for --tier, not an API key
|
|
446
440
|
} else if (!apiKey) {
|
|
447
441
|
apiKey = arg
|
|
448
442
|
}
|
|
@@ -456,15 +450,11 @@ export function parseArgs(argv) {
|
|
|
456
450
|
const aiderMode = flags.includes('--aider')
|
|
457
451
|
const crushMode = flags.includes('--crush')
|
|
458
452
|
const gooseMode = flags.includes('--goose')
|
|
459
|
-
const claudeCodeMode = flags.includes('--claude-code')
|
|
460
|
-
const codexMode = flags.includes('--codex')
|
|
461
|
-
const geminiMode = flags.includes('--gemini')
|
|
462
453
|
const qwenMode = flags.includes('--qwen')
|
|
463
454
|
const openHandsMode = flags.includes('--openhands')
|
|
464
455
|
const ampMode = flags.includes('--amp')
|
|
465
456
|
const piMode = flags.includes('--pi')
|
|
466
457
|
const noTelemetry = flags.includes('--no-telemetry')
|
|
467
|
-
const cleanProxyMode = flags.includes('--clean-proxy') || flags.includes('--proxy-clean')
|
|
468
458
|
const jsonMode = flags.includes('--json')
|
|
469
459
|
const helpMode = flags.includes('--help') || flags.includes('-h')
|
|
470
460
|
const premiumMode = flags.includes('--premium')
|
|
@@ -482,7 +472,7 @@ export function parseArgs(argv) {
|
|
|
482
472
|
let pingInterval = pingIntervalValueIdx !== -1 ? parseInt(args[pingIntervalValueIdx], 10) : null
|
|
483
473
|
let sortDirection = sortDesc ? 'desc' : (sortAscFlag ? 'asc' : null)
|
|
484
474
|
|
|
485
|
-
|
|
475
|
+
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
486
476
|
|
|
487
477
|
// 📖 --recommend — launch directly into Smart Recommend mode (Q key equivalent)
|
|
488
478
|
const recommendMode = flags.includes('--recommend')
|
|
@@ -497,15 +487,11 @@ export function parseArgs(argv) {
|
|
|
497
487
|
aiderMode,
|
|
498
488
|
crushMode,
|
|
499
489
|
gooseMode,
|
|
500
|
-
claudeCodeMode,
|
|
501
|
-
codexMode,
|
|
502
|
-
geminiMode,
|
|
503
490
|
qwenMode,
|
|
504
491
|
openHandsMode,
|
|
505
492
|
ampMode,
|
|
506
493
|
piMode,
|
|
507
494
|
noTelemetry,
|
|
508
|
-
cleanProxyMode,
|
|
509
495
|
jsonMode,
|
|
510
496
|
helpMode,
|
|
511
497
|
tierFilter,
|
|
@@ -517,8 +503,8 @@ export function parseArgs(argv) {
|
|
|
517
503
|
showUnconfigured,
|
|
518
504
|
disableWidthsWarning,
|
|
519
505
|
premiumMode,
|
|
520
|
-
|
|
521
|
-
recommendMode
|
|
506
|
+
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
507
|
+
recommendMode,
|
|
522
508
|
}
|
|
523
509
|
}
|
|
524
510
|
|
|
@@ -692,61 +678,6 @@ export function getTopRecommendations(results, taskType, priority, contextBudget
|
|
|
692
678
|
return scored.slice(0, topN)
|
|
693
679
|
}
|
|
694
680
|
|
|
695
|
-
/**
|
|
696
|
-
* 📖 getProxyStatusInfo: Pure function that maps startup proxy status + active proxy state
|
|
697
|
-
* 📖 to a normalised descriptor object consumed by the TUI footer indicator.
|
|
698
|
-
*
|
|
699
|
-
* 📖 Priority of evaluation:
|
|
700
|
-
* 1. proxyStartupStatus.phase === 'starting' → state:'starting'
|
|
701
|
-
* 2. proxyStartupStatus.phase === 'running' → state:'running' with port/accountCount
|
|
702
|
-
* 3. proxyStartupStatus.phase === 'failed' → state:'failed' with truncated reason
|
|
703
|
-
* 4. isProxyActive (legacy activeProxy flag) → state:'running' (no port detail)
|
|
704
|
-
* 5. isProxyEnabled → state:'configured'
|
|
705
|
-
* 6. otherwise → state:'stopped'
|
|
706
|
-
*
|
|
707
|
-
* 📖 Reason is clamped to 80 characters to keep footer readable (no stack traces).
|
|
708
|
-
*
|
|
709
|
-
* @param {object|null} proxyStartupStatus — state.proxyStartupStatus value
|
|
710
|
-
* @param {boolean} isProxyActive — truthy when the module-level activeProxy is non-null
|
|
711
|
-
* @param {boolean} [isProxyEnabled=false] — truthy when proxy mode is enabled in settings
|
|
712
|
-
* @returns {{ state: string, port?: number, accountCount?: number, reason?: string }}
|
|
713
|
-
*/
|
|
714
|
-
export function getProxyStatusInfo(proxyStartupStatus, isProxyActive, isProxyEnabled = false) {
|
|
715
|
-
const MAX_REASON = 80
|
|
716
|
-
|
|
717
|
-
if (proxyStartupStatus) {
|
|
718
|
-
const { phase } = proxyStartupStatus
|
|
719
|
-
if (phase === 'starting') {
|
|
720
|
-
return { state: 'starting' }
|
|
721
|
-
}
|
|
722
|
-
if (phase === 'running') {
|
|
723
|
-
return {
|
|
724
|
-
state: 'running',
|
|
725
|
-
port: proxyStartupStatus.port,
|
|
726
|
-
accountCount: proxyStartupStatus.accountCount,
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
if (phase === 'failed') {
|
|
730
|
-
const raw = proxyStartupStatus.reason ?? 'unknown error'
|
|
731
|
-
return {
|
|
732
|
-
state: 'failed',
|
|
733
|
-
reason: raw.length > MAX_REASON ? raw.slice(0, MAX_REASON - 1) + '…' : raw,
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// 📖 Legacy fallback: activeProxy set directly (e.g. from manual proxy start without startup status)
|
|
739
|
-
if (isProxyActive) {
|
|
740
|
-
return { state: 'running' }
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (isProxyEnabled) {
|
|
744
|
-
return { state: 'configured' }
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
return { state: 'stopped' }
|
|
748
|
-
}
|
|
749
|
-
|
|
750
681
|
/**
|
|
751
682
|
* 📖 getVersionStatusInfo turns startup + manual update-check state into a compact,
|
|
752
683
|
* 📖 render-friendly footer descriptor for the main table.
|
package/bin/fcm-proxy-daemon.js
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @file bin/fcm-proxy-daemon.js
|
|
5
|
-
* @description Standalone headless FCM proxy daemon — runs independently of the TUI.
|
|
6
|
-
*
|
|
7
|
-
* 📖 This is the always-on background proxy server. It reads the user's config
|
|
8
|
-
* (~/.free-coding-models.json), builds the proxy topology (merged models × API keys),
|
|
9
|
-
* and starts a ProxyServer on a stable port with a stable token.
|
|
10
|
-
*
|
|
11
|
-
* 📖 When installed as a launchd LaunchAgent (macOS) or systemd user service (Linux),
|
|
12
|
-
* this daemon starts at login and persists across reboots, allowing Claude Code,
|
|
13
|
-
* Gemini CLI, OpenCode, and all other tools to access free models 24/7.
|
|
14
|
-
*
|
|
15
|
-
* 📖 Status file: ~/.free-coding-models/daemon.json
|
|
16
|
-
* Contains PID, port, token, version, model/account counts. The TUI reads this
|
|
17
|
-
* to detect a running daemon and delegate instead of starting an in-process proxy.
|
|
18
|
-
*
|
|
19
|
-
* 📖 Hot-reload: Watches ~/.free-coding-models.json for changes and reloads the
|
|
20
|
-
* proxy topology (accounts, models) without restarting the process.
|
|
21
|
-
*
|
|
22
|
-
* @see src/proxy-topology.js — shared topology builder
|
|
23
|
-
* @see src/proxy-server.js — ProxyServer implementation
|
|
24
|
-
* @see src/daemon-manager.js — install/uninstall/status management
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, watch } from 'node:fs'
|
|
28
|
-
import { join } from 'node:path'
|
|
29
|
-
import { homedir } from 'node:os'
|
|
30
|
-
import { createRequire } from 'node:module'
|
|
31
|
-
import { fileURLToPath } from 'node:url'
|
|
32
|
-
|
|
33
|
-
// 📖 Resolve package.json for version info
|
|
34
|
-
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
35
|
-
let PKG_VERSION = 'unknown'
|
|
36
|
-
try {
|
|
37
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
|
|
38
|
-
PKG_VERSION = pkg.version || 'unknown'
|
|
39
|
-
} catch { /* ignore */ }
|
|
40
|
-
|
|
41
|
-
// 📖 Config + data paths
|
|
42
|
-
const CONFIG_PATH = join(homedir(), '.free-coding-models.json')
|
|
43
|
-
const DATA_DIR = join(homedir(), '.free-coding-models')
|
|
44
|
-
const DAEMON_STATUS_FILE = join(DATA_DIR, 'daemon.json')
|
|
45
|
-
const LOG_PREFIX = '[fcm-daemon]'
|
|
46
|
-
|
|
47
|
-
// 📖 Default daemon port — high port unlikely to conflict
|
|
48
|
-
const DEFAULT_DAEMON_PORT = 18045
|
|
49
|
-
|
|
50
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
function log(msg) {
|
|
53
|
-
console.log(`${LOG_PREFIX} ${new Date().toISOString()} ${msg}`)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function logError(msg) {
|
|
57
|
-
console.error(`${LOG_PREFIX} ${new Date().toISOString()} ERROR: ${msg}`)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 📖 Write daemon status file so TUI and tools can discover the running daemon.
|
|
62
|
-
*/
|
|
63
|
-
function writeDaemonStatus(info) {
|
|
64
|
-
if (!existsSync(DATA_DIR)) {
|
|
65
|
-
mkdirSync(DATA_DIR, { mode: 0o700, recursive: true })
|
|
66
|
-
}
|
|
67
|
-
writeFileSync(DAEMON_STATUS_FILE, JSON.stringify(info, null, 2), { mode: 0o600 })
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* 📖 Remove daemon status file on shutdown.
|
|
72
|
-
*/
|
|
73
|
-
function removeDaemonStatus() {
|
|
74
|
-
try {
|
|
75
|
-
if (existsSync(DAEMON_STATUS_FILE)) unlinkSync(DAEMON_STATUS_FILE)
|
|
76
|
-
} catch { /* best-effort */ }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
async function main() {
|
|
82
|
-
log(`Starting FCM Proxy V2 v${PKG_VERSION} (PID: ${process.pid})`)
|
|
83
|
-
|
|
84
|
-
// 📖 Dynamic imports — keep startup fast, avoid loading TUI-specific modules
|
|
85
|
-
const { loadConfig, getProxySettings } = await import('../src/config.js')
|
|
86
|
-
const { ProxyServer } = await import('../src/proxy-server.js')
|
|
87
|
-
const { buildProxyTopologyFromConfig, buildMergedModelsForDaemon } = await import('../src/proxy-topology.js')
|
|
88
|
-
const { sources } = await import('../sources.js')
|
|
89
|
-
|
|
90
|
-
// 📖 Load config and build initial topology — wrapped in try/catch to provide clear error on startup failures
|
|
91
|
-
let fcmConfig, proxySettings, mergedModels, accounts, proxyModels, anthropicRouting
|
|
92
|
-
try {
|
|
93
|
-
fcmConfig = loadConfig()
|
|
94
|
-
proxySettings = getProxySettings(fcmConfig)
|
|
95
|
-
} catch (err) {
|
|
96
|
-
logError(`Fatal: Failed to load config: ${err.message}`)
|
|
97
|
-
process.exit(1)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!proxySettings.stableToken) {
|
|
101
|
-
logError('No stableToken in proxy settings — run the TUI first to initialize config.')
|
|
102
|
-
process.exit(1)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const port = proxySettings.preferredPort || DEFAULT_DAEMON_PORT
|
|
106
|
-
const token = proxySettings.stableToken
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
log(`Building merged model catalog...`)
|
|
110
|
-
mergedModels = await buildMergedModelsForDaemon()
|
|
111
|
-
log(`Merged ${mergedModels.length} model groups`)
|
|
112
|
-
|
|
113
|
-
const topology = buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources)
|
|
114
|
-
accounts = topology.accounts
|
|
115
|
-
proxyModels = topology.proxyModels
|
|
116
|
-
anthropicRouting = topology.anthropicRouting
|
|
117
|
-
} catch (err) {
|
|
118
|
-
logError(`Fatal: Failed to build initial topology: ${err.message}`)
|
|
119
|
-
process.exit(1)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (accounts.length === 0) {
|
|
123
|
-
logError('No API keys configured — FCM Proxy V2 has no accounts to serve. Add keys via the TUI.')
|
|
124
|
-
process.exit(1)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
log(`Built proxy topology: ${accounts.length} accounts across ${Object.keys(proxyModels).length} models`)
|
|
128
|
-
|
|
129
|
-
// 📖 Start the proxy server
|
|
130
|
-
const proxy = new ProxyServer({
|
|
131
|
-
port,
|
|
132
|
-
accounts,
|
|
133
|
-
proxyApiKey: token,
|
|
134
|
-
anthropicRouting,
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
const { port: listeningPort } = await proxy.start()
|
|
139
|
-
log(`Proxy listening on 127.0.0.1:${listeningPort}`)
|
|
140
|
-
|
|
141
|
-
// 📖 Write status file for TUI discovery
|
|
142
|
-
const statusInfo = {
|
|
143
|
-
pid: process.pid,
|
|
144
|
-
port: listeningPort,
|
|
145
|
-
token,
|
|
146
|
-
startedAt: new Date().toISOString(),
|
|
147
|
-
version: PKG_VERSION,
|
|
148
|
-
modelCount: Object.keys(proxyModels).length,
|
|
149
|
-
accountCount: accounts.length,
|
|
150
|
-
}
|
|
151
|
-
writeDaemonStatus(statusInfo)
|
|
152
|
-
log(`Status file written to ${DAEMON_STATUS_FILE}`)
|
|
153
|
-
|
|
154
|
-
// 📖 Set up config file watcher for hot-reload
|
|
155
|
-
let reloadTimeout = null
|
|
156
|
-
// 📖 Prevents concurrent reloads — if a reload is in progress, the next
|
|
157
|
-
// watcher event will be queued (one pending max) instead of stacking
|
|
158
|
-
let reloadInProgress = false
|
|
159
|
-
let reloadQueued = false
|
|
160
|
-
const configWatcher = watch(CONFIG_PATH, () => {
|
|
161
|
-
// 📖 Debounce 1s — config writes can trigger multiple fs events
|
|
162
|
-
if (reloadTimeout) clearTimeout(reloadTimeout)
|
|
163
|
-
reloadTimeout = setTimeout(async () => {
|
|
164
|
-
if (reloadInProgress) {
|
|
165
|
-
reloadQueued = true
|
|
166
|
-
return
|
|
167
|
-
}
|
|
168
|
-
reloadInProgress = true
|
|
169
|
-
try {
|
|
170
|
-
log('Config file changed — reloading topology...')
|
|
171
|
-
fcmConfig = loadConfig()
|
|
172
|
-
mergedModels = await buildMergedModelsForDaemon()
|
|
173
|
-
const newTopology = buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources)
|
|
174
|
-
|
|
175
|
-
if (newTopology.accounts.length === 0) {
|
|
176
|
-
log('Warning: new topology has 0 accounts — keeping current topology')
|
|
177
|
-
return
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
proxy.updateAccounts(newTopology.accounts, newTopology.anthropicRouting)
|
|
181
|
-
accounts = newTopology.accounts
|
|
182
|
-
proxyModels = newTopology.proxyModels
|
|
183
|
-
anthropicRouting = newTopology.anthropicRouting
|
|
184
|
-
|
|
185
|
-
// 📖 Update status file
|
|
186
|
-
writeDaemonStatus({
|
|
187
|
-
...statusInfo,
|
|
188
|
-
modelCount: Object.keys(proxyModels).length,
|
|
189
|
-
accountCount: accounts.length,
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
log(`Topology reloaded: ${accounts.length} accounts, ${Object.keys(proxyModels).length} models`)
|
|
193
|
-
} catch (err) {
|
|
194
|
-
logError(`Hot-reload failed: ${err.message}`)
|
|
195
|
-
} finally {
|
|
196
|
-
reloadInProgress = false
|
|
197
|
-
// 📖 If another reload was queued during this one, trigger it now
|
|
198
|
-
if (reloadQueued) {
|
|
199
|
-
reloadQueued = false
|
|
200
|
-
configWatcher.emit('change')
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}, 1000)
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
// 📖 Graceful shutdown
|
|
207
|
-
const shutdown = async (signal) => {
|
|
208
|
-
log(`Received ${signal} — shutting down...`)
|
|
209
|
-
if (reloadTimeout) clearTimeout(reloadTimeout)
|
|
210
|
-
configWatcher.close()
|
|
211
|
-
try {
|
|
212
|
-
await proxy.stop()
|
|
213
|
-
} catch { /* best-effort */ }
|
|
214
|
-
removeDaemonStatus()
|
|
215
|
-
log('FCM Proxy V2 stopped cleanly.')
|
|
216
|
-
process.exit(0)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
220
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
221
|
-
process.on('exit', () => removeDaemonStatus())
|
|
222
|
-
|
|
223
|
-
// 📖 Keep the process alive
|
|
224
|
-
log('FCM Proxy V2 ready. Waiting for requests...')
|
|
225
|
-
|
|
226
|
-
} catch (err) {
|
|
227
|
-
if (err.code === 'EADDRINUSE') {
|
|
228
|
-
logError(`Port ${port} is already in use. Another FCM Proxy V2 instance may be running, or another process occupies this port.`)
|
|
229
|
-
logError(`Change proxy.preferredPort in ~/.free-coding-models.json or stop the conflicting process.`)
|
|
230
|
-
process.exit(2)
|
|
231
|
-
}
|
|
232
|
-
logError(`Failed to start proxy: ${err.message}`)
|
|
233
|
-
removeDaemonStatus()
|
|
234
|
-
process.exit(1)
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
main().catch(err => {
|
|
239
|
-
logError(`Fatal: ${err.message}`)
|
|
240
|
-
removeDaemonStatus()
|
|
241
|
-
process.exit(1)
|
|
242
|
-
})
|