free-coding-models 0.3.11 → 0.3.13
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 +24 -0
- package/README.md +112 -1134
- package/bin/free-coding-models.js +18 -170
- package/package.json +2 -3
- package/src/cli-help.js +0 -18
- package/src/config.js +5 -117
- package/src/endpoint-installer.js +26 -64
- package/src/key-handler.js +90 -443
- 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 +28 -520
- package/src/product-flags.js +14 -0
- package/src/render-helpers.js +2 -34
- package/src/render-table.js +11 -19
- 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 +3 -68
- 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 -157
- package/src/log-reader.js +0 -195
- package/src/opencode-sync.js +0 -200
- package/src/proxy-foreground.js +0 -234
- package/src/proxy-server.js +0 -1506
- package/src/proxy-sync.js +0 -591
- 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/daemon-manager.js
DELETED
|
@@ -1,527 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file src/daemon-manager.js
|
|
3
|
-
* @description OS-level service management for the FCM proxy daemon.
|
|
4
|
-
*
|
|
5
|
-
* 📖 Handles install/uninstall/status/restart of the always-on background proxy:
|
|
6
|
-
* - macOS: launchd LaunchAgent (~/Library/LaunchAgents/com.fcm.proxy.plist)
|
|
7
|
-
* - Linux: systemd user service (~/.config/systemd/user/fcm-proxy.service)
|
|
8
|
-
* - Windows: gracefully unsupported (falls back to in-process proxy)
|
|
9
|
-
*
|
|
10
|
-
* 📖 The daemon manager reads/writes daemon.json for IPC between TUI and daemon.
|
|
11
|
-
* It never imports TUI-specific modules (chalk, render-table, etc.).
|
|
12
|
-
*
|
|
13
|
-
* @functions
|
|
14
|
-
* → getDaemonStatus() — read daemon.json, verify PID alive, health-check HTTP
|
|
15
|
-
* → isDaemonRunning() — quick boolean check
|
|
16
|
-
* → getDaemonInfo() — raw daemon.json contents
|
|
17
|
-
* → installDaemon() — write plist/service, load/enable via OS (blocked in dev)
|
|
18
|
-
* → uninstallDaemon() — unload/disable, delete plist/service
|
|
19
|
-
* → restartDaemon() — stop + start via OS service manager
|
|
20
|
-
* → stopDaemon() — send SIGTERM without removing the service
|
|
21
|
-
* → killDaemonProcess() — send SIGKILL (emergency)
|
|
22
|
-
* → getVersionMismatch() — detect daemon vs FCM version drift
|
|
23
|
-
* → getDaemonLogPath() — path to daemon stdout log
|
|
24
|
-
* → getPlatformSupport() — { supported: boolean, platform: string, reason?: string }
|
|
25
|
-
*
|
|
26
|
-
* @exports getDaemonStatus, isDaemonRunning, getDaemonInfo, installDaemon, uninstallDaemon, restartDaemon, stopDaemon, killDaemonProcess, getVersionMismatch, getDaemonLogPath, getPlatformSupport
|
|
27
|
-
* @see bin/fcm-proxy-daemon.js — the actual daemon process
|
|
28
|
-
* @see src/config.js — DAEMON_DATA_DIR for status file location
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
32
|
-
import { join, dirname } from 'node:path'
|
|
33
|
-
import { homedir } from 'node:os'
|
|
34
|
-
import { execSync } from 'node:child_process'
|
|
35
|
-
import { fileURLToPath } from 'node:url'
|
|
36
|
-
import http from 'node:http'
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* 📖 Safe wrapper around execSync with a timeout to prevent indefinite hangs.
|
|
40
|
-
* @param {string} cmd
|
|
41
|
-
* @param {number} [timeoutMs=15000]
|
|
42
|
-
* @returns {Buffer}
|
|
43
|
-
*/
|
|
44
|
-
function execSyncSafe(cmd, timeoutMs = 15000) {
|
|
45
|
-
return execSync(cmd, { stdio: 'pipe', timeout: timeoutMs })
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// 📖 Paths
|
|
49
|
-
const DATA_DIR = join(homedir(), '.free-coding-models')
|
|
50
|
-
const DAEMON_STATUS_FILE = join(DATA_DIR, 'daemon.json')
|
|
51
|
-
|
|
52
|
-
// 📖 macOS launchd paths
|
|
53
|
-
const LAUNCH_AGENT_LABEL = 'com.fcm.proxy'
|
|
54
|
-
const LAUNCH_AGENTS_DIR = join(homedir(), 'Library', 'LaunchAgents')
|
|
55
|
-
const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LAUNCH_AGENT_LABEL}.plist`)
|
|
56
|
-
|
|
57
|
-
// 📖 Linux systemd paths
|
|
58
|
-
const SYSTEMD_USER_DIR = join(homedir(), '.config', 'systemd', 'user')
|
|
59
|
-
const SERVICE_NAME = 'fcm-proxy'
|
|
60
|
-
const SERVICE_PATH = join(SYSTEMD_USER_DIR, `${SERVICE_NAME}.service`)
|
|
61
|
-
|
|
62
|
-
// 📖 Log paths
|
|
63
|
-
const DAEMON_STDOUT_LOG = join(DATA_DIR, 'daemon-stdout.log')
|
|
64
|
-
const DAEMON_STDERR_LOG = join(DATA_DIR, 'daemon-stderr.log')
|
|
65
|
-
|
|
66
|
-
// ─── Platform detection ──────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 📖 Check if the current platform supports daemon installation.
|
|
70
|
-
* @returns {{ supported: boolean, platform: 'macos'|'linux'|'unsupported', reason?: string }}
|
|
71
|
-
*/
|
|
72
|
-
export function getPlatformSupport() {
|
|
73
|
-
if (process.platform === 'darwin') return { supported: true, platform: 'macos' }
|
|
74
|
-
if (process.platform === 'linux') return { supported: true, platform: 'linux' }
|
|
75
|
-
return { supported: false, platform: 'unsupported', reason: `Platform '${process.platform}' is not supported for daemon mode. Use in-process proxy instead.` }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ─── Resolve daemon script path ──────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 📖 Get absolute path to the fcm-proxy-daemon.js script.
|
|
82
|
-
* 📖 Works for both npm global installs and local development.
|
|
83
|
-
*/
|
|
84
|
-
function getDaemonScriptPath() {
|
|
85
|
-
const thisFile = fileURLToPath(import.meta.url)
|
|
86
|
-
return join(dirname(thisFile), '..', 'bin', 'fcm-proxy-daemon.js')
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* 📖 Detect if we're running from a local dev checkout (not a global npm install).
|
|
91
|
-
* 📖 A global npm install lives in a node_modules path. A dev checkout has a .git dir.
|
|
92
|
-
* 📖 We block daemon install from dev to avoid hardcoding repo-local paths in launchd/systemd.
|
|
93
|
-
*/
|
|
94
|
-
function isDevEnvironment() {
|
|
95
|
-
const thisFile = fileURLToPath(import.meta.url)
|
|
96
|
-
const projectRoot = join(dirname(thisFile), '..')
|
|
97
|
-
// 📖 If there's a .git directory at project root, it's a dev checkout
|
|
98
|
-
return existsSync(join(projectRoot, '.git'))
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* 📖 Get absolute path to node binary.
|
|
103
|
-
*/
|
|
104
|
-
function getNodePath() {
|
|
105
|
-
return process.execPath
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ─── Daemon status ───────────────────────────────────────────────────────────
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* 📖 Read the raw daemon.json status file.
|
|
112
|
-
* @returns {object|null} parsed daemon.json or null if not found
|
|
113
|
-
*/
|
|
114
|
-
export function getDaemonInfo() {
|
|
115
|
-
try {
|
|
116
|
-
if (!existsSync(DAEMON_STATUS_FILE)) return null
|
|
117
|
-
return JSON.parse(readFileSync(DAEMON_STATUS_FILE, 'utf8'))
|
|
118
|
-
} catch {
|
|
119
|
-
return null
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* 📖 Check if a PID is alive using signal 0.
|
|
125
|
-
* @param {number} pid
|
|
126
|
-
* @returns {boolean}
|
|
127
|
-
*/
|
|
128
|
-
function isPidAlive(pid) {
|
|
129
|
-
try {
|
|
130
|
-
process.kill(pid, 0)
|
|
131
|
-
return true
|
|
132
|
-
} catch (err) {
|
|
133
|
-
// 📖 ESRCH = no such process, EPERM = exists but different user
|
|
134
|
-
return err.code === 'EPERM'
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* 📖 HTTP health check against the daemon's /v1/health endpoint.
|
|
140
|
-
* @param {number} port
|
|
141
|
-
* @param {number} [timeoutMs=3000]
|
|
142
|
-
* @returns {Promise<{ ok: boolean, data?: object }>}
|
|
143
|
-
*/
|
|
144
|
-
// 📖 Max buffer for health check responses (64 KB) — prevents memory issues from rogue endpoints
|
|
145
|
-
const MAX_HEALTH_BUFFER = 64 * 1024
|
|
146
|
-
|
|
147
|
-
function healthCheck(port, timeoutMs = 3000) {
|
|
148
|
-
return new Promise(resolve => {
|
|
149
|
-
const req = http.get(`http://127.0.0.1:${port}/v1/health`, { timeout: timeoutMs }, res => {
|
|
150
|
-
const chunks = []
|
|
151
|
-
let totalSize = 0
|
|
152
|
-
res.on('data', c => {
|
|
153
|
-
totalSize += c.length
|
|
154
|
-
if (totalSize > MAX_HEALTH_BUFFER) {
|
|
155
|
-
res.destroy()
|
|
156
|
-
return resolve({ ok: false })
|
|
157
|
-
}
|
|
158
|
-
chunks.push(c)
|
|
159
|
-
})
|
|
160
|
-
res.on('end', () => {
|
|
161
|
-
try {
|
|
162
|
-
const data = JSON.parse(Buffer.concat(chunks).toString())
|
|
163
|
-
resolve({ ok: res.statusCode === 200, data })
|
|
164
|
-
} catch {
|
|
165
|
-
resolve({ ok: false })
|
|
166
|
-
}
|
|
167
|
-
})
|
|
168
|
-
})
|
|
169
|
-
req.on('error', () => resolve({ ok: false }))
|
|
170
|
-
req.on('timeout', () => { req.destroy(); resolve({ ok: false }) })
|
|
171
|
-
})
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* 📖 Get comprehensive daemon status.
|
|
176
|
-
* @returns {Promise<{ status: 'running'|'stopped'|'stale'|'unhealthy'|'not-installed', info?: object, health?: object }>}
|
|
177
|
-
*/
|
|
178
|
-
export async function getDaemonStatus() {
|
|
179
|
-
const info = getDaemonInfo()
|
|
180
|
-
|
|
181
|
-
if (!info) {
|
|
182
|
-
// 📖 Check if service files exist (installed but daemon.json missing)
|
|
183
|
-
const serviceInstalled = existsSync(PLIST_PATH) || existsSync(SERVICE_PATH)
|
|
184
|
-
return { status: serviceInstalled ? 'stopped' : 'not-installed' }
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// 📖 Verify PID is alive
|
|
188
|
-
if (!isPidAlive(info.pid)) {
|
|
189
|
-
// 📖 Stale daemon.json — daemon crashed without cleanup
|
|
190
|
-
try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
|
|
191
|
-
return { status: 'stale', info }
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// 📖 PID alive — health check the HTTP endpoint
|
|
195
|
-
const health = await healthCheck(info.port)
|
|
196
|
-
if (!health.ok) {
|
|
197
|
-
return { status: 'unhealthy', info, health }
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return { status: 'running', info, health }
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* 📖 Quick boolean check if daemon is running and healthy.
|
|
205
|
-
* @returns {Promise<boolean>}
|
|
206
|
-
*/
|
|
207
|
-
export async function isDaemonRunning() {
|
|
208
|
-
const { status } = await getDaemonStatus()
|
|
209
|
-
return status === 'running'
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ─── Install daemon ──────────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* 📖 Install and start the daemon as an OS background service.
|
|
216
|
-
* 📖 Blocked in dev environments to avoid hardcoding repo-local paths
|
|
217
|
-
* in launchd/systemd service files. Use `pnpm start` + in-process proxy for dev.
|
|
218
|
-
* @returns {{ success: boolean, error?: string }}
|
|
219
|
-
*/
|
|
220
|
-
export function installDaemon() {
|
|
221
|
-
const platform = getPlatformSupport()
|
|
222
|
-
if (!platform.supported) {
|
|
223
|
-
return { success: false, error: platform.reason }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 📖 Block daemon install from dev checkouts — the plist/service would
|
|
227
|
-
// hardcode paths to your local repo, which breaks on npm update.
|
|
228
|
-
if (isDevEnvironment()) {
|
|
229
|
-
return { success: false, error: 'Cannot install daemon from a dev checkout (has .git). Install free-coding-models globally via npm/pnpm first.' }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const nodePath = getNodePath()
|
|
233
|
-
const daemonScript = getDaemonScriptPath()
|
|
234
|
-
|
|
235
|
-
if (!existsSync(daemonScript)) {
|
|
236
|
-
return { success: false, error: `Daemon script not found at ${daemonScript}` }
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// 📖 Ensure data directory exists for logs
|
|
240
|
-
if (!existsSync(DATA_DIR)) {
|
|
241
|
-
mkdirSync(DATA_DIR, { mode: 0o700, recursive: true })
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
try {
|
|
245
|
-
if (platform.platform === 'macos') {
|
|
246
|
-
return installMacOS(nodePath, daemonScript)
|
|
247
|
-
} else {
|
|
248
|
-
return installLinux(nodePath, daemonScript)
|
|
249
|
-
}
|
|
250
|
-
} catch (err) {
|
|
251
|
-
return { success: false, error: err.message }
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* 📖 macOS: Write LaunchAgent plist and load it.
|
|
257
|
-
*/
|
|
258
|
-
function installMacOS(nodePath, daemonScript) {
|
|
259
|
-
// 📖 Ensure LaunchAgents directory exists
|
|
260
|
-
if (!existsSync(LAUNCH_AGENTS_DIR)) {
|
|
261
|
-
mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true })
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// 📖 Unload existing agent if any (ignore errors)
|
|
265
|
-
try {
|
|
266
|
-
execSyncSafe(`launchctl unload "${PLIST_PATH}" 2>/dev/null`)
|
|
267
|
-
} catch { /* ignore */ }
|
|
268
|
-
|
|
269
|
-
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
270
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
271
|
-
<plist version="1.0">
|
|
272
|
-
<dict>
|
|
273
|
-
<key>Label</key>
|
|
274
|
-
<string>${LAUNCH_AGENT_LABEL}</string>
|
|
275
|
-
<key>ProgramArguments</key>
|
|
276
|
-
<array>
|
|
277
|
-
<string>${nodePath}</string>
|
|
278
|
-
<string>${daemonScript}</string>
|
|
279
|
-
</array>
|
|
280
|
-
<key>KeepAlive</key>
|
|
281
|
-
<true/>
|
|
282
|
-
<key>RunAtLoad</key>
|
|
283
|
-
<true/>
|
|
284
|
-
<key>StandardOutPath</key>
|
|
285
|
-
<string>${DAEMON_STDOUT_LOG}</string>
|
|
286
|
-
<key>StandardErrorPath</key>
|
|
287
|
-
<string>${DAEMON_STDERR_LOG}</string>
|
|
288
|
-
<key>EnvironmentVariables</key>
|
|
289
|
-
<dict>
|
|
290
|
-
<key>HOME</key>
|
|
291
|
-
<string>${homedir()}</string>
|
|
292
|
-
<key>PATH</key>
|
|
293
|
-
<string>${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}</string>
|
|
294
|
-
</dict>
|
|
295
|
-
</dict>
|
|
296
|
-
</plist>
|
|
297
|
-
`
|
|
298
|
-
|
|
299
|
-
try {
|
|
300
|
-
writeFileSync(PLIST_PATH, plistContent)
|
|
301
|
-
} catch (err) {
|
|
302
|
-
throw new Error('Failed to write LaunchAgent plist: ' + err.message)
|
|
303
|
-
}
|
|
304
|
-
execSyncSafe(`launchctl load "${PLIST_PATH}"`)
|
|
305
|
-
|
|
306
|
-
return { success: true }
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* 📖 Linux: Write systemd user service and enable/start it.
|
|
311
|
-
*/
|
|
312
|
-
function installLinux(nodePath, daemonScript) {
|
|
313
|
-
if (!existsSync(SYSTEMD_USER_DIR)) {
|
|
314
|
-
mkdirSync(SYSTEMD_USER_DIR, { recursive: true })
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const serviceContent = `[Unit]
|
|
318
|
-
Description=FCM Proxy V2 — Always-on model rotation proxy
|
|
319
|
-
After=network.target
|
|
320
|
-
|
|
321
|
-
[Service]
|
|
322
|
-
Type=simple
|
|
323
|
-
ExecStart=${nodePath} ${daemonScript}
|
|
324
|
-
Restart=always
|
|
325
|
-
RestartSec=5
|
|
326
|
-
Environment=HOME=${homedir()}
|
|
327
|
-
Environment=PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}
|
|
328
|
-
StandardOutput=append:${DAEMON_STDOUT_LOG}
|
|
329
|
-
StandardError=append:${DAEMON_STDERR_LOG}
|
|
330
|
-
|
|
331
|
-
[Install]
|
|
332
|
-
WantedBy=default.target
|
|
333
|
-
`
|
|
334
|
-
|
|
335
|
-
try {
|
|
336
|
-
writeFileSync(SERVICE_PATH, serviceContent)
|
|
337
|
-
} catch (err) {
|
|
338
|
-
throw new Error('Failed to write systemd service file: ' + err.message)
|
|
339
|
-
}
|
|
340
|
-
execSyncSafe('systemctl --user daemon-reload')
|
|
341
|
-
execSyncSafe(`systemctl --user enable ${SERVICE_NAME}`)
|
|
342
|
-
execSyncSafe(`systemctl --user start ${SERVICE_NAME}`)
|
|
343
|
-
|
|
344
|
-
// 📖 Enable lingering so service survives logout
|
|
345
|
-
try {
|
|
346
|
-
execSyncSafe(`loginctl enable-linger ${process.env.USER || ''}`)
|
|
347
|
-
} catch { /* non-fatal — might need sudo */ }
|
|
348
|
-
|
|
349
|
-
return { success: true }
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// ─── Uninstall daemon ────────────────────────────────────────────────────────
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* 📖 Stop and remove the daemon OS service.
|
|
356
|
-
* @returns {{ success: boolean, error?: string }}
|
|
357
|
-
*/
|
|
358
|
-
export function uninstallDaemon() {
|
|
359
|
-
const platform = getPlatformSupport()
|
|
360
|
-
if (!platform.supported) {
|
|
361
|
-
return { success: false, error: platform.reason }
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
try {
|
|
365
|
-
if (platform.platform === 'macos') {
|
|
366
|
-
return uninstallMacOS()
|
|
367
|
-
} else {
|
|
368
|
-
return uninstallLinux()
|
|
369
|
-
}
|
|
370
|
-
} catch (err) {
|
|
371
|
-
return { success: false, error: err.message }
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function uninstallMacOS() {
|
|
376
|
-
try {
|
|
377
|
-
execSyncSafe(`launchctl unload "${PLIST_PATH}" 2>/dev/null`)
|
|
378
|
-
} catch { /* ignore */ }
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
if (existsSync(PLIST_PATH)) unlinkSync(PLIST_PATH)
|
|
382
|
-
} catch { /* ignore */ }
|
|
383
|
-
|
|
384
|
-
// 📖 Clean up daemon status file
|
|
385
|
-
try {
|
|
386
|
-
if (existsSync(DAEMON_STATUS_FILE)) unlinkSync(DAEMON_STATUS_FILE)
|
|
387
|
-
} catch { /* ignore */ }
|
|
388
|
-
|
|
389
|
-
return { success: true }
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function uninstallLinux() {
|
|
393
|
-
try {
|
|
394
|
-
execSyncSafe(`systemctl --user stop ${SERVICE_NAME} 2>/dev/null`)
|
|
395
|
-
execSyncSafe(`systemctl --user disable ${SERVICE_NAME} 2>/dev/null`)
|
|
396
|
-
} catch { /* ignore */ }
|
|
397
|
-
|
|
398
|
-
try {
|
|
399
|
-
if (existsSync(SERVICE_PATH)) unlinkSync(SERVICE_PATH)
|
|
400
|
-
execSyncSafe('systemctl --user daemon-reload')
|
|
401
|
-
} catch { /* ignore */ }
|
|
402
|
-
|
|
403
|
-
try {
|
|
404
|
-
if (existsSync(DAEMON_STATUS_FILE)) unlinkSync(DAEMON_STATUS_FILE)
|
|
405
|
-
} catch { /* ignore */ }
|
|
406
|
-
|
|
407
|
-
return { success: true }
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// ─── Restart daemon ──────────────────────────────────────────────────────────
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* 📖 Restart the daemon via the OS service manager.
|
|
414
|
-
* @returns {{ success: boolean, error?: string }}
|
|
415
|
-
*/
|
|
416
|
-
export function restartDaemon() {
|
|
417
|
-
const platform = getPlatformSupport()
|
|
418
|
-
if (!platform.supported) {
|
|
419
|
-
return { success: false, error: platform.reason }
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
try {
|
|
423
|
-
if (platform.platform === 'macos') {
|
|
424
|
-
// 📖 launchd: unload + load to restart
|
|
425
|
-
try { execSyncSafe(`launchctl unload "${PLIST_PATH}" 2>/dev/null`) } catch { /* ignore */ }
|
|
426
|
-
execSyncSafe(`launchctl load "${PLIST_PATH}"`)
|
|
427
|
-
} else {
|
|
428
|
-
execSyncSafe(`systemctl --user restart ${SERVICE_NAME}`)
|
|
429
|
-
}
|
|
430
|
-
return { success: true }
|
|
431
|
-
} catch (err) {
|
|
432
|
-
return { success: false, error: err.message }
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// ─── Stop daemon (kill without uninstalling the service) ────────────────────
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* 📖 Stop the daemon process without removing the OS service.
|
|
440
|
-
* 📖 On macOS with KeepAlive:true, launchd may restart it automatically.
|
|
441
|
-
* Use uninstallDaemon() if you want it gone permanently.
|
|
442
|
-
* @returns {{ success: boolean, error?: string, willRestart?: boolean }}
|
|
443
|
-
*/
|
|
444
|
-
export function stopDaemon() {
|
|
445
|
-
const info = getDaemonInfo()
|
|
446
|
-
if (!info?.pid) {
|
|
447
|
-
return { success: false, error: 'No daemon running (no daemon.json found).' }
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (!isPidAlive(info.pid)) {
|
|
451
|
-
// 📖 Already dead, clean up stale status file
|
|
452
|
-
try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
|
|
453
|
-
return { success: true }
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
try {
|
|
457
|
-
process.kill(info.pid, 'SIGTERM')
|
|
458
|
-
} catch (err) {
|
|
459
|
-
return { success: false, error: `Failed to send SIGTERM to PID ${info.pid}: ${err.message}` }
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// 📖 Clean up status file
|
|
463
|
-
try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
|
|
464
|
-
|
|
465
|
-
// 📖 Warn the user if the OS service will auto-restart it
|
|
466
|
-
const serviceInstalled = existsSync(PLIST_PATH) || existsSync(SERVICE_PATH)
|
|
467
|
-
return {
|
|
468
|
-
success: true,
|
|
469
|
-
willRestart: serviceInstalled,
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* 📖 Force-kill the daemon process (SIGKILL). Emergency escape hatch.
|
|
475
|
-
* @returns {{ success: boolean, error?: string }}
|
|
476
|
-
*/
|
|
477
|
-
export function killDaemonProcess() {
|
|
478
|
-
const info = getDaemonInfo()
|
|
479
|
-
if (!info?.pid) {
|
|
480
|
-
return { success: false, error: 'No daemon PID found.' }
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
try {
|
|
484
|
-
if (isPidAlive(info.pid)) {
|
|
485
|
-
process.kill(info.pid, 'SIGKILL')
|
|
486
|
-
}
|
|
487
|
-
} catch (err) {
|
|
488
|
-
return { success: false, error: `Failed to kill PID ${info.pid}: ${err.message}` }
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// 📖 Clean up status file
|
|
492
|
-
try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
|
|
493
|
-
return { success: true }
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// ─── Version mismatch detection ─────────────────────────────────────────────
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* 📖 Check if the running daemon version differs from the installed FCM version.
|
|
500
|
-
* 📖 Returns null if no mismatch, otherwise { daemonVersion, installedVersion }.
|
|
501
|
-
* @returns {{ daemonVersion: string, installedVersion: string } | null}
|
|
502
|
-
*/
|
|
503
|
-
export function getVersionMismatch() {
|
|
504
|
-
const info = getDaemonInfo()
|
|
505
|
-
if (!info?.version) return null
|
|
506
|
-
|
|
507
|
-
try {
|
|
508
|
-
const thisFile = fileURLToPath(import.meta.url)
|
|
509
|
-
const pkgPath = join(dirname(thisFile), '..', 'package.json')
|
|
510
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
511
|
-
if (pkg.version && pkg.version !== info.version) {
|
|
512
|
-
return { daemonVersion: info.version, installedVersion: pkg.version }
|
|
513
|
-
}
|
|
514
|
-
} catch { /* ignore */ }
|
|
515
|
-
|
|
516
|
-
return null
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// ─── Log path ────────────────────────────────────────────────────────────────
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* 📖 Get path to the daemon stdout log.
|
|
523
|
-
* @returns {string}
|
|
524
|
-
*/
|
|
525
|
-
export function getDaemonLogPath() {
|
|
526
|
-
return DAEMON_STDOUT_LOG
|
|
527
|
-
}
|
package/src/error-classifier.js
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Error types:
|
|
3
|
-
* - QUOTA_EXHAUSTED: Skip account until quota resets
|
|
4
|
-
* - RATE_LIMITED: Backoff, try another account
|
|
5
|
-
* - MODEL_CAPACITY: Server overloaded, retry after delay
|
|
6
|
-
* - SERVER_ERROR: Backoff, count toward circuit breaker
|
|
7
|
-
* - AUTH_ERROR: Disable account permanently
|
|
8
|
-
* - NETWORK_ERROR: Connection failure, try another
|
|
9
|
-
* - MODEL_NOT_FOUND: Provider does not have/serve this model; skip and try next account
|
|
10
|
-
* - UNKNOWN: Generic, no retry
|
|
11
|
-
*/
|
|
12
|
-
export const ErrorType = {
|
|
13
|
-
QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
|
|
14
|
-
RATE_LIMITED: 'RATE_LIMITED',
|
|
15
|
-
MODEL_CAPACITY: 'MODEL_CAPACITY',
|
|
16
|
-
SERVER_ERROR: 'SERVER_ERROR',
|
|
17
|
-
AUTH_ERROR: 'AUTH_ERROR',
|
|
18
|
-
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
19
|
-
MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
|
|
20
|
-
UNKNOWN: 'UNKNOWN',
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const QUOTA_KEYWORDS = ['quota', 'limit exceeded', 'billing', 'insufficient_quota', 'exceeded your']
|
|
24
|
-
const CAPACITY_KEYWORDS = ['overloaded', 'capacity', 'busy', 'unavailable']
|
|
25
|
-
/**
|
|
26
|
-
* Keywords that indicate a provider-level 404/410 means the model is not
|
|
27
|
-
* available on *this account/provider*, not a generic routing 404.
|
|
28
|
-
* These trigger rotation to the next provider rather than forwarding the error.
|
|
29
|
-
*/
|
|
30
|
-
const MODEL_NOT_FOUND_KEYWORDS = [
|
|
31
|
-
'model not found',
|
|
32
|
-
'inaccessible',
|
|
33
|
-
'not deployed',
|
|
34
|
-
'model is not available',
|
|
35
|
-
'model unavailable',
|
|
36
|
-
'no such model',
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Classify the confidence level for a 429 response.
|
|
41
|
-
*
|
|
42
|
-
* Returns:
|
|
43
|
-
* - 'quota_exhaustion_likely' — body contains keywords indicating the account's quota is depleted
|
|
44
|
-
* - 'generic_rate_limit' — plain rate-limit with no quota-specific signal (or non-429 status)
|
|
45
|
-
*
|
|
46
|
-
* @param {number} statusCode
|
|
47
|
-
* @param {string} body
|
|
48
|
-
* @param {Object} headers
|
|
49
|
-
* @returns {'quota_exhaustion_likely'|'generic_rate_limit'}
|
|
50
|
-
*/
|
|
51
|
-
export function rateLimitConfidence(statusCode, body, headers) {
|
|
52
|
-
if (statusCode !== 429) return 'generic_rate_limit'
|
|
53
|
-
const bodyLower = (body || '').toLowerCase()
|
|
54
|
-
const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
|
|
55
|
-
return isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Classify an HTTP error response.
|
|
60
|
-
* @param {number} statusCode - 0 for network errors
|
|
61
|
-
* @param {string} body - Response body text or error message
|
|
62
|
-
* @param {Object} headers - Response headers (lowercased keys)
|
|
63
|
-
* @returns {{ type: string, retryAfterSec: number|null, shouldRetry: boolean, skipAccount: boolean, rateLimitConfidence?: string }}
|
|
64
|
-
*/
|
|
65
|
-
export function classifyError(statusCode, body, headers) {
|
|
66
|
-
const bodyLower = (body || '').toLowerCase()
|
|
67
|
-
const retryAfter = headers?.['retry-after']
|
|
68
|
-
const retryAfterSec = retryAfter ? parseInt(retryAfter, 10) || null : null
|
|
69
|
-
|
|
70
|
-
// Network/connection errors
|
|
71
|
-
if (statusCode === 0 || statusCode === undefined) {
|
|
72
|
-
return { type: ErrorType.NETWORK_ERROR, retryAfterSec: 5, shouldRetry: true, skipAccount: false }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// 📖 401/403 from upstream providers: skip this account and try the next one.
|
|
76
|
-
// 📖 A provider may reject a valid key for specific models (e.g. nvidia 403 on gpt-oss-120b)
|
|
77
|
-
// 📖 while another provider with the same proxyModelId may accept it.
|
|
78
|
-
if (statusCode === 401 || statusCode === 403) {
|
|
79
|
-
return { type: ErrorType.AUTH_ERROR, retryAfterSec: null, shouldRetry: true, skipAccount: true }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Provider-level 404/410: model not found / inaccessible / not deployed on this account.
|
|
83
|
-
// These are NOT generic routing 404s — they mean this specific provider doesn't serve
|
|
84
|
-
// the requested model. Rotate to the next account rather than forwarding the error.
|
|
85
|
-
if (statusCode === 404 || statusCode === 410) {
|
|
86
|
-
const isModelNotFound = MODEL_NOT_FOUND_KEYWORDS.some(kw => bodyLower.includes(kw))
|
|
87
|
-
if (isModelNotFound) {
|
|
88
|
-
return { type: ErrorType.MODEL_NOT_FOUND, retryAfterSec: null, shouldRetry: true, skipAccount: true }
|
|
89
|
-
}
|
|
90
|
-
// Generic 404 (wrong URL, endpoint not found, etc.) — not retryable
|
|
91
|
-
return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (statusCode === 429) {
|
|
95
|
-
const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
|
|
96
|
-
const confidence = isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
|
|
97
|
-
if (isQuota) {
|
|
98
|
-
return { type: ErrorType.QUOTA_EXHAUSTED, retryAfterSec, shouldRetry: true, skipAccount: true, rateLimitConfidence: confidence }
|
|
99
|
-
}
|
|
100
|
-
return { type: ErrorType.RATE_LIMITED, retryAfterSec, shouldRetry: true, skipAccount: false, rateLimitConfidence: confidence }
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (statusCode === 503 || statusCode === 502) {
|
|
104
|
-
return { type: ErrorType.MODEL_CAPACITY, retryAfterSec: retryAfterSec || 5, shouldRetry: true, skipAccount: false }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (statusCode >= 500) {
|
|
108
|
-
return { type: ErrorType.SERVER_ERROR, retryAfterSec: retryAfterSec || 10, shouldRetry: true, skipAccount: false }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Circuit breaker: CLOSED → OPEN (after threshold failures) → HALF_OPEN (after cooldown) → CLOSED (on success) or → OPEN (on failure)
|
|
116
|
-
*/
|
|
117
|
-
export class CircuitBreaker {
|
|
118
|
-
constructor({ threshold = 5, cooldownMs = 60000 } = {}) {
|
|
119
|
-
this.threshold = threshold
|
|
120
|
-
this.cooldownMs = cooldownMs
|
|
121
|
-
this.consecutiveFailures = 0
|
|
122
|
-
this.openedAt = null
|
|
123
|
-
this.state = 'CLOSED'
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
recordFailure() {
|
|
127
|
-
this.consecutiveFailures++
|
|
128
|
-
if (this.consecutiveFailures >= this.threshold || this.state === 'HALF_OPEN') {
|
|
129
|
-
this.state = 'OPEN'
|
|
130
|
-
this.openedAt = Date.now()
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
recordSuccess() {
|
|
135
|
-
this.consecutiveFailures = 0
|
|
136
|
-
this.state = 'CLOSED'
|
|
137
|
-
this.openedAt = null
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
isOpen() {
|
|
141
|
-
if (this.state === 'CLOSED') return false
|
|
142
|
-
if (this.state === 'OPEN' && Date.now() - this.openedAt >= this.cooldownMs) {
|
|
143
|
-
this.state = 'HALF_OPEN'
|
|
144
|
-
return false
|
|
145
|
-
}
|
|
146
|
-
if (this.state === 'HALF_OPEN') return false
|
|
147
|
-
return true
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
isHalfOpen() { return this.state === 'HALF_OPEN' }
|
|
151
|
-
|
|
152
|
-
reset() {
|
|
153
|
-
this.consecutiveFailures = 0
|
|
154
|
-
this.state = 'CLOSED'
|
|
155
|
-
this.openedAt = null
|
|
156
|
-
}
|
|
157
|
-
}
|