free-coding-models 0.5.28 → 0.5.30
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/README.md +1 -1
- package/bin/free-coding-models.js +10 -2
- package/changelog/v0.5.29.md +19 -0
- package/changelog/v0.5.30.md +15 -0
- package/package.json +1 -1
- package/sources.js +3 -2
- package/src/core/config.js +1 -14
- package/src/core/endpoint-installer.js +5 -9
- package/src/core/legacy-proxy-cleanup.js +2 -0
- package/src/core/model-merger.js +4 -13
- package/src/core/playground.js +6 -4
- package/src/core/router-daemon.js +101 -43
- package/src/core/router-dashboard.js +63 -25
- package/src/core/shared-helpers.js +117 -0
- package/src/core/sync-set.js +11 -24
- package/src/core/tool-launchers.js +24 -92
- package/src/core/utils.js +26 -100
- package/src/tui/app.js +2 -2
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +77 -20
- package/src/tui/render-table.js +3 -3
- package/web/dist/assets/index-CsFt3qt5.css +1 -0
- package/web/dist/assets/index-I6N91rH7.js +40 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/index.html +2 -2
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/server.js +34 -4
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
- package/web/src/components/playground/PlaygroundChat.jsx +527 -0
- package/web/src/components/playground/PlaygroundChat.module.css +382 -0
- package/web/src/components/playground/PlaygroundView.jsx +104 -442
- package/web/src/components/playground/PlaygroundView.module.css +10 -0
- package/web/src/components/router/RouterView.jsx +268 -56
- package/web/src/components/router/RouterView.module.css +416 -1
- package/src/core/product-flags.js +0 -9
- package/web/dist/assets/index-BRFowJv5.css +0 -1
- package/web/dist/assets/index-Pr3waI0-.js +0 -41
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* @exports fetchRouterSets, createRouterSet, renameRouterSet, duplicateRouterSet
|
|
34
34
|
* @exports deleteRouterSet, activateRouterSet, updateRouterSetModels
|
|
35
35
|
* @exports addModelToRouterSet, removeModelFromRouterSet, reorderRouterSetModel
|
|
36
|
+
* @exports setDashboardNotice
|
|
36
37
|
*
|
|
37
38
|
* @see ./router-daemon.js — daemon endpoints consumed by this screen
|
|
38
39
|
* @see ./overlays.js — overlay factory that mounts this renderer
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
import chalk from 'chalk'
|
|
43
44
|
import { existsSync, readFileSync } from 'node:fs'
|
|
44
45
|
import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
45
|
-
import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT,
|
|
46
|
+
import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, getRouterPidPath, getRouterPortPath, getRouterPortRange } from './router-daemon.js'
|
|
46
47
|
import { themeColors, getTierRgb } from '../tui/theme.js'
|
|
47
48
|
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
48
49
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
@@ -150,14 +151,16 @@ async function fetchJson(url, options = {}) {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
function readDaemonFiles() {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
154
|
+
const portPath = getRouterPortPath()
|
|
155
|
+
const pidPath = getRouterPidPath()
|
|
156
|
+
const recordedPort = readNumberFile(portPath)
|
|
157
|
+
const recordedPid = readNumberFile(pidPath)
|
|
155
158
|
return {
|
|
156
159
|
port: recordedPort,
|
|
157
160
|
pid: recordedPid,
|
|
158
161
|
pidAlive: recordedPid ? isProcessAlive(recordedPid) : false,
|
|
159
|
-
hasPidFile: existsSync(
|
|
160
|
-
hasPortFile: existsSync(
|
|
162
|
+
hasPidFile: existsSync(pidPath),
|
|
163
|
+
hasPortFile: existsSync(portPath),
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
|
|
@@ -168,7 +171,7 @@ function buildPortCandidates(state) {
|
|
|
168
171
|
? state.routerDashboardBaseUrl.match(/:(\d+)$/)
|
|
169
172
|
: null
|
|
170
173
|
const baseUrlPort = baseUrlMatch ? Number.parseInt(baseUrlMatch[1], 10) : null
|
|
171
|
-
const filePort = readNumberFile(
|
|
174
|
+
const filePort = readNumberFile(getRouterPortPath())
|
|
172
175
|
const { defaultPort, maxPort } = getRouterPortRange()
|
|
173
176
|
for (const port of [baseUrlPort, currentPort, filePort, defaultPort]) {
|
|
174
177
|
if (Number.isInteger(port) && port > 0 && !ports.includes(port)) ports.push(port)
|
|
@@ -259,6 +262,21 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
|
|
|
259
262
|
const merged = { ...health, ...stats }
|
|
260
263
|
const models = Array.isArray(stats.models) ? stats.models.map(normalizeModelHealth) : []
|
|
261
264
|
const requestLog = Array.isArray(stats.requestLog) ? stats.requestLog.map(normalizeRequestEntry) : []
|
|
265
|
+
// 📖 routingOrder — priority-first attempt order from /stats (issue #120).
|
|
266
|
+
// 📖 routingOrder[0].key is the model that will serve the next request, so the
|
|
267
|
+
// 📖 TUI can mark it with a ▶ NEXT glyph. Guarded so older daemons without
|
|
268
|
+
// 📖 the field still render fine.
|
|
269
|
+
const routingOrder = Array.isArray(stats.routingOrder)
|
|
270
|
+
? stats.routingOrder
|
|
271
|
+
.filter((entry) => isRecord(entry) && typeof entry.key === 'string')
|
|
272
|
+
.map((entry) => ({
|
|
273
|
+
key: entry.key,
|
|
274
|
+
provider: safeString(entry.provider, ''),
|
|
275
|
+
model: safeString(entry.model, ''),
|
|
276
|
+
priority: toFiniteNumber(entry.priority, 0),
|
|
277
|
+
state: safeString(entry.state, 'UNKNOWN'),
|
|
278
|
+
}))
|
|
279
|
+
: []
|
|
262
280
|
|
|
263
281
|
return {
|
|
264
282
|
ok: merged.ok === true,
|
|
@@ -281,6 +299,7 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
|
|
|
281
299
|
stalePid: toFiniteNumber(merged.stalePid, null),
|
|
282
300
|
tokens: normalizeTokens(stats.tokens),
|
|
283
301
|
models,
|
|
302
|
+
routingOrder,
|
|
284
303
|
requestLog,
|
|
285
304
|
}
|
|
286
305
|
}
|
|
@@ -331,7 +350,7 @@ function statusBadge(status, snapshot) {
|
|
|
331
350
|
return themeColors.error('○ UNREACHABLE')
|
|
332
351
|
}
|
|
333
352
|
|
|
334
|
-
function setDashboardNotice(state, type, message, ttlMs = 3500) {
|
|
353
|
+
export function setDashboardNotice(state, type, message, ttlMs = 3500) {
|
|
335
354
|
state.routerDashboardNotice = { type, message, at: Date.now() }
|
|
336
355
|
if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
|
|
337
356
|
state.routerDashboardNoticeTimer = setTimeout(() => {
|
|
@@ -843,15 +862,19 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
843
862
|
lines.push(` ${paintBanner(bannerLine)}`)
|
|
844
863
|
lines.push('')
|
|
845
864
|
|
|
846
|
-
// ── Quick Setup (connection info)
|
|
847
|
-
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
lines.push(` ${themeColors.
|
|
852
|
-
lines.push(` ${themeColors.dim('
|
|
865
|
+
// ── Quick Setup (connection info) — HERO section ──────────────────────────
|
|
866
|
+
// 📖 Always visible with default port 19280 so users can copy even when stopped.
|
|
867
|
+
const { defaultPort: currentDefaultPort } = getRouterPortRange()
|
|
868
|
+
const port = snapshot.port || state.routerDashboardPort || currentDefaultPort
|
|
869
|
+
const baseUrl = `http://localhost:${port}/v1`
|
|
870
|
+
lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('— paste into your coding tool config')}`)
|
|
871
|
+
lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(baseUrl)}`)
|
|
872
|
+
lines.push(` ${themeColors.dim('Model')} ${themeColors.infoBold('fcm')}`)
|
|
873
|
+
lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
|
|
853
874
|
if (isRunning) {
|
|
854
875
|
lines.push(` ${themeColors.dim('Uptime')} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim('Requests routed:')} ${themeColors.info(String(snapshot.requestsRouted))}`)
|
|
876
|
+
} else {
|
|
877
|
+
lines.push(` ${themeColors.dim('Hint')} ${themeColors.dim('Start the daemon to enable routing')}`)
|
|
855
878
|
}
|
|
856
879
|
lines.push(` ${separator}`)
|
|
857
880
|
lines.push('')
|
|
@@ -868,7 +891,8 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
868
891
|
const cursor = state.routerDashboardCursorIndex ?? 0
|
|
869
892
|
|
|
870
893
|
if (favorites.length === 0) {
|
|
871
|
-
lines.push(` ${themeColors.warning('No favorites yet.
|
|
894
|
+
lines.push(` ${themeColors.warning('No favorites yet. Press Esc, then F on any model to add it.')}`)
|
|
895
|
+
lines.push(` ${themeColors.dim('Favorites become your router fallback chain — #1 is tried first.')}`)
|
|
872
896
|
} else {
|
|
873
897
|
// 📖 Priority keycap glyphs for the fallback order
|
|
874
898
|
const KEYCAPS = ['1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟']
|
|
@@ -880,8 +904,13 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
880
904
|
healthByKey.set(`${m.provider}/${m.model}`, m)
|
|
881
905
|
}
|
|
882
906
|
|
|
883
|
-
// 📖
|
|
884
|
-
|
|
907
|
+
// 📖 The model the daemon will serve on the next request (priority-first,
|
|
908
|
+
// 📖 see issue #120). Marked with ▶ NEXT so the user understands the top
|
|
909
|
+
// 📖 of the chain is what actually handles traffic — not whichever is fastest.
|
|
910
|
+
const nextToServeKey = snapshot.routingOrder?.[0]?.key || null
|
|
911
|
+
|
|
912
|
+
// 📖 Column headers — leading space lines up with the ▶ NEXT marker column.
|
|
913
|
+
lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 42))} ${themeColors.dim(padEndDisplay('STATUS', 16))} ${themeColors.dim(padEndDisplay('AVG PING', 8))} ${themeColors.dim('VERDICT')}`)
|
|
885
914
|
|
|
886
915
|
for (let i = 0; i < favorites.length; i++) {
|
|
887
916
|
const favKey = favorites[i]
|
|
@@ -926,14 +955,14 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
926
955
|
}
|
|
927
956
|
|
|
928
957
|
// 📖 Get global metrics from main table state
|
|
929
|
-
let avgPingDisplay = themeColors.dim('
|
|
958
|
+
let avgPingDisplay = themeColors.dim('—')
|
|
930
959
|
let verdictDisplay = themeColors.dim('Pending ⏳')
|
|
931
960
|
|
|
932
961
|
if (mainResult) {
|
|
933
962
|
// Avg Ping
|
|
934
963
|
const avg = getAvg(mainResult)
|
|
935
964
|
if (avg !== Infinity) {
|
|
936
|
-
const str =
|
|
965
|
+
const str = `${avg}ms`
|
|
937
966
|
avgPingDisplay = avg < 500 ? themeColors.metricGood(str) : avg < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
|
|
938
967
|
}
|
|
939
968
|
|
|
@@ -957,7 +986,13 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
957
986
|
verdictDisplay = padEndDisplay(verdictDisplay, 14)
|
|
958
987
|
}
|
|
959
988
|
|
|
960
|
-
|
|
989
|
+
// 📖 Prefix the next-to-serve model with a ▶ NEXT marker so the active
|
|
990
|
+
// 📖 routing target is obvious. Only shown when the daemon is actually
|
|
991
|
+
// 📖 running and has reported a routing order (stopped → no marker).
|
|
992
|
+
const nextMarker = (nextToServeKey && nextToServeKey === favKey)
|
|
993
|
+
? themeColors.successBold('▶')
|
|
994
|
+
: themeColors.dim(' ')
|
|
995
|
+
const rowText = ` ${nextMarker} ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 16)} ${padEndDisplay(avgPingDisplay, 8)} ${verdictDisplay}`
|
|
961
996
|
|
|
962
997
|
if (isCursorRow) {
|
|
963
998
|
lines.push(themeColors.bgCursor(rowText + ' '.repeat(Math.max(0, width - displayWidth(rowText) - 3))))
|
|
@@ -996,14 +1031,14 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
996
1031
|
lines.push(` ${separator}`)
|
|
997
1032
|
lines.push('')
|
|
998
1033
|
|
|
999
|
-
// ── Token Summary (compact)
|
|
1000
|
-
lines.push(` ${themeColors.textBold('Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim('
|
|
1034
|
+
// ── Token Summary (compact, visual) ─────────────────────────────────────────
|
|
1035
|
+
lines.push(` ${themeColors.textBold('📊 Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.today.requests} req)`)} ${themeColors.dim('Lifetime:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.all_time.requests} req)`)}`)
|
|
1001
1036
|
|
|
1002
1037
|
// ── Live Request Log (compact) ──────────────────────────────────────────────
|
|
1003
1038
|
const requestRows = requestLogRows(state, snapshot)
|
|
1039
|
+
lines.push('')
|
|
1040
|
+
lines.push(` ${themeColors.textBold('Recent Requests')}`)
|
|
1004
1041
|
if (requestRows.length > 0) {
|
|
1005
|
-
lines.push('')
|
|
1006
|
-
lines.push(` ${themeColors.textBold('Recent Requests')}`)
|
|
1007
1042
|
const header = ` ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
|
|
1008
1043
|
lines.push(themeColors.dim(header))
|
|
1009
1044
|
for (const row of requestRows.slice(0, 6)) {
|
|
@@ -1025,6 +1060,8 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1025
1060
|
`${compactText(detail, Math.max(10, width - 68)).trimEnd()}`
|
|
1026
1061
|
)
|
|
1027
1062
|
}
|
|
1063
|
+
} else {
|
|
1064
|
+
lines.push(` ${themeColors.dim('No requests routed yet')}`)
|
|
1028
1065
|
}
|
|
1029
1066
|
|
|
1030
1067
|
// ── Health check speed ──────────────────────────────────────────────────────
|
|
@@ -1048,7 +1085,8 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1048
1085
|
|
|
1049
1086
|
// ── Footer ──────────────────────────────────────────────────────────────────
|
|
1050
1087
|
lines.push('')
|
|
1051
|
-
lines.push(` ${
|
|
1088
|
+
lines.push(` ${separator}`)
|
|
1089
|
+
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Sync best')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
|
|
1052
1090
|
|
|
1053
1091
|
const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
|
|
1054
1092
|
state.routerDashboardScrollOffset = offset
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file shared-helpers.js
|
|
3
|
+
* @description Shared utility functions used across multiple modules.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 DRY helpers extracted from router-daemon.js, tool-launchers.js,
|
|
7
|
+
* 📖 endpoint-installer.js, and legacy-proxy-cleanup.js to eliminate
|
|
8
|
+
* duplicate implementations of the same patterns.
|
|
9
|
+
*
|
|
10
|
+
* @functions
|
|
11
|
+
* → sleep — Promise-based setTimeout
|
|
12
|
+
* → ensureDir — Create parent directory if missing
|
|
13
|
+
* → readJson — Read and parse JSON file with fallback
|
|
14
|
+
* → writeJson — Write JSON file with directory creation
|
|
15
|
+
* → atomicWriteJson — Atomic write via temp file + rename
|
|
16
|
+
* → safeJsonParse — JSON.parse with fallback
|
|
17
|
+
* → maskApiKey — Mask API key for display (show last 4 chars)
|
|
18
|
+
*
|
|
19
|
+
* @exports sleep, ensureDir, readJson, writeJson, atomicWriteJson, safeJsonParse, maskApiKey
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { dirname } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 📖 Promise-based sleep. Used by daemon probe staggering, TUI animations, etc.
|
|
27
|
+
* @param {number} ms
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
export function sleep(ms) {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 📖 Create parent directory of `filePath` if it doesn't exist.
|
|
36
|
+
* @param {string} filePath
|
|
37
|
+
*/
|
|
38
|
+
export function ensureDir(filePath) {
|
|
39
|
+
const dir = dirname(filePath)
|
|
40
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 📖 Read and parse a JSON file. Returns `fallback` on any error.
|
|
45
|
+
* @param {string} filePath
|
|
46
|
+
* @param {*} [fallback=null]
|
|
47
|
+
* @returns {*}
|
|
48
|
+
*/
|
|
49
|
+
export function readJson(filePath, fallback = null) {
|
|
50
|
+
if (!existsSync(filePath)) return fallback
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
53
|
+
} catch {
|
|
54
|
+
return fallback
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 📖 Write JSON to file, creating parent directories as needed.
|
|
60
|
+
* @param {string} filePath
|
|
61
|
+
* @param {*} value
|
|
62
|
+
* @param {object} [options]
|
|
63
|
+
* @param {boolean} [options.backup=false] — Not implemented here; callers handle it
|
|
64
|
+
*/
|
|
65
|
+
export function writeJson(filePath, value) {
|
|
66
|
+
ensureDir(filePath)
|
|
67
|
+
writeFileSync(filePath, JSON.stringify(value, null, 2))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 📖 Atomic JSON write: writes to a temp file, then renames over the target.
|
|
72
|
+
* Prevents partial writes from corrupting the file on crash.
|
|
73
|
+
* @param {string} path
|
|
74
|
+
* @param {*} data
|
|
75
|
+
* @param {number} [mode=0o600]
|
|
76
|
+
*/
|
|
77
|
+
export function atomicWriteJson(path, data, mode = 0o600) {
|
|
78
|
+
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
79
|
+
writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode })
|
|
80
|
+
renameSync(tempPath, path)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 📖 JSON.parse with fallback. Returns `fallback` on parse failure.
|
|
85
|
+
* @param {string} raw
|
|
86
|
+
* @param {*} [fallback=null]
|
|
87
|
+
* @returns {*}
|
|
88
|
+
*/
|
|
89
|
+
export function safeJsonParse(raw, fallback = null) {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(raw)
|
|
92
|
+
} catch {
|
|
93
|
+
return fallback
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 📖 Mask an API key for display. Shows last 4 chars, rest as bullets.
|
|
99
|
+
* @param {string} key
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* 📖 Check if a provider supports routing (has chat/completions URL, not CLI-only).
|
|
104
|
+
* @param {string} providerKey
|
|
105
|
+
* @param {Record<string, {url?: string, cliOnly?: boolean}>} sources — provider catalog
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function isRouteableProvider(providerKey, sources) {
|
|
109
|
+
const source = sources[providerKey]
|
|
110
|
+
return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function maskApiKey(key) {
|
|
114
|
+
if (!key || typeof key !== 'string') return ''
|
|
115
|
+
if (key.length <= 8) return '••••••••'
|
|
116
|
+
return '••••••••' + key.slice(-4)
|
|
117
|
+
}
|
package/src/core/sync-set.js
CHANGED
|
@@ -37,11 +37,10 @@ import {
|
|
|
37
37
|
saveConfig,
|
|
38
38
|
} from './config.js'
|
|
39
39
|
import { resolveCloudflareUrl } from './ping.js'
|
|
40
|
-
import { ROUTER_PID_PATH } from './router-daemon.js'
|
|
40
|
+
import { ROUTER_PID_PATH, getRouterPidPath } from './router-daemon.js'
|
|
41
41
|
import { existsSync, readFileSync } from 'node:fs'
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
42
|
+
import { TIER_ORDER, parseSweToNum } from './utils.js'
|
|
43
|
+
import { isRouteableProvider } from './shared-helpers.js'
|
|
45
44
|
|
|
46
45
|
// 📖 Numeric value per tier for composite scoring.
|
|
47
46
|
const TIER_SCORES = {
|
|
@@ -69,14 +68,7 @@ const OPENROUTER_FREE_MODEL_IDS = new Set([
|
|
|
69
68
|
'openrouter/owl-alpha',
|
|
70
69
|
])
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
* Check whether a provider's catalog entry supports routing (has a
|
|
74
|
-
* chat/completions URL and is not CLI-only).
|
|
75
|
-
*/
|
|
76
|
-
function isRouteableProvider(providerKey) {
|
|
77
|
-
const source = sources[providerKey]
|
|
78
|
-
return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
|
|
79
|
-
}
|
|
71
|
+
// 📖 isRouteableProvider imported from shared-helpers.js (needs `sources` param)
|
|
80
72
|
|
|
81
73
|
/**
|
|
82
74
|
* Resolve the upstream URL for a provider, handling Cloudflare template substitution.
|
|
@@ -98,14 +90,7 @@ function isOpenRouterFreeModelId(modelId) {
|
|
|
98
90
|
return String(modelId).endsWith(':free') || OPENROUTER_FREE_MODEL_IDS.has(String(modelId))
|
|
99
91
|
}
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
* Parse a SWE-bench percentage string like "49.2%" to a number.
|
|
103
|
-
*/
|
|
104
|
-
function parseSwePercent(value) {
|
|
105
|
-
if (typeof value !== 'string') return 0
|
|
106
|
-
const numeric = parseFloat(value.replace('%', '').trim())
|
|
107
|
-
return Number.isFinite(numeric) ? numeric : 0
|
|
108
|
-
}
|
|
93
|
+
// 📖 parseSwePercent replaced by shared parseSweToNum (same logic)
|
|
109
94
|
|
|
110
95
|
/**
|
|
111
96
|
* Score a candidate model for ranking. Higher is better.
|
|
@@ -156,12 +141,12 @@ export function buildSyncCandidates(apiKeys, options = {}) {
|
|
|
156
141
|
|
|
157
142
|
for (const [providerKey, sourceData] of Object.entries(sources)) {
|
|
158
143
|
if (!apiKeys[providerKey]) continue
|
|
159
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
144
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
160
145
|
|
|
161
146
|
for (const tuple of sourceData.models || []) {
|
|
162
147
|
const [modelId, label = '', tier = '', swe = '0%'] = tuple
|
|
163
148
|
if (typeof modelId !== 'string' || !modelId.trim()) continue
|
|
164
|
-
const swePercent =
|
|
149
|
+
const swePercent = parseSweToNum(swe)
|
|
165
150
|
if (shouldSkipModel(providerKey, modelId, tier, swePercent, options)) continue
|
|
166
151
|
const score = scoreCandidate(providerKey, modelId, label, tier, swePercent)
|
|
167
152
|
candidates.push({
|
|
@@ -323,8 +308,10 @@ export async function probeModel(candidate, apiKey) {
|
|
|
323
308
|
*/
|
|
324
309
|
function signalDaemonReload() {
|
|
325
310
|
try {
|
|
326
|
-
|
|
327
|
-
const
|
|
311
|
+
// 📖 Dynamic resolver so dev checkouts signal the dev daemon (FCM_DEV=1).
|
|
312
|
+
const pidPath = getRouterPidPath()
|
|
313
|
+
if (!existsSync(pidPath)) return false
|
|
314
|
+
const pid = Number(readFileSync(pidPath, 'utf8').trim())
|
|
328
315
|
if (!Number.isFinite(pid) || pid <= 0) return false
|
|
329
316
|
process.kill(pid, 'SIGHUP')
|
|
330
317
|
return true
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import chalk from 'chalk'
|
|
40
40
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
|
|
41
41
|
import { homedir } from 'os'
|
|
42
|
-
import {
|
|
42
|
+
import { join } from 'path'
|
|
43
43
|
import { spawn, spawnSync } from 'child_process'
|
|
44
44
|
import { sources } from '../../sources.js'
|
|
45
45
|
import { PROVIDER_COLOR } from '../tui/render-table.js'
|
|
@@ -48,6 +48,7 @@ import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
|
|
|
48
48
|
import { getToolMeta, TOOL_METADATA } from './tool-metadata.js'
|
|
49
49
|
import { PROVIDER_METADATA } from './provider-metadata.js'
|
|
50
50
|
import { resolveToolBinaryPath } from './tool-bootstrap.js'
|
|
51
|
+
import { ensureDir, readJson, writeJson } from './shared-helpers.js'
|
|
51
52
|
|
|
52
53
|
const OPENAI_COMPAT_ENV_KEYS = [
|
|
53
54
|
'OPENAI_API_KEY',
|
|
@@ -60,11 +61,6 @@ const OPENAI_COMPAT_ENV_KEYS = [
|
|
|
60
61
|
]
|
|
61
62
|
const SANITIZED_TOOL_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS]
|
|
62
63
|
|
|
63
|
-
function ensureDir(filePath) {
|
|
64
|
-
const dir = dirname(filePath)
|
|
65
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
66
|
-
}
|
|
67
|
-
|
|
68
64
|
// 📖 Parse a context window string (e.g. "128k", "1M", "32k") to token count number.
|
|
69
65
|
function parseCtxToTokens(ctx) {
|
|
70
66
|
if (!ctx || typeof ctx !== 'string') return null
|
|
@@ -104,19 +100,7 @@ function backupIfExists(filePath) {
|
|
|
104
100
|
return backupPath
|
|
105
101
|
}
|
|
106
102
|
|
|
107
|
-
|
|
108
|
-
if (!existsSync(filePath)) return fallback
|
|
109
|
-
try {
|
|
110
|
-
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
111
|
-
} catch {
|
|
112
|
-
return fallback
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function writeJson(filePath, value) {
|
|
117
|
-
ensureDir(filePath)
|
|
118
|
-
writeFileSync(filePath, JSON.stringify(value, null, 2))
|
|
119
|
-
}
|
|
103
|
+
// 📖 readJson/writeJson imported from shared-helpers.js
|
|
120
104
|
|
|
121
105
|
function getProviderBaseUrl(providerKey) {
|
|
122
106
|
const url = sources[providerKey]?.url
|
|
@@ -966,55 +950,8 @@ export async function startExternalTool(mode, model, config) {
|
|
|
966
950
|
console.log(chalk.cyan(` ▶ Launching ${meta.label} with ${chalk.bold(model.label)}...`))
|
|
967
951
|
printConfigArtifacts(meta.label, launchPlan.configArtifacts)
|
|
968
952
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
if (mode === 'crush') {
|
|
974
|
-
console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
|
|
975
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
if (mode === 'goose') {
|
|
979
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
if (mode === 'qwen') {
|
|
983
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
if (mode === 'openhands') {
|
|
987
|
-
console.log(chalk.dim(` 📖 OpenHands launched with model: ${model.modelId}`))
|
|
988
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
if (mode === 'amp') {
|
|
992
|
-
console.log(chalk.dim(` 📖 Amp config updated with model: ${model.modelId}`))
|
|
993
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
if (mode === 'pi') {
|
|
997
|
-
// 📖 Pi supports --provider and --model flags for guaranteed auto-selection
|
|
998
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
if (mode === 'hermes') {
|
|
1002
|
-
// 📖 Restart the Hermes gateway so the new model config takes effect immediately
|
|
1003
|
-
restartHermesGateway()
|
|
1004
|
-
console.log(chalk.dim(` 📖 Hermes Agent configured with model: ${model.modelId}`))
|
|
1005
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
if (mode === 'continue') {
|
|
1009
|
-
console.log(chalk.dim(` 📖 Continue CLI configured with model: ${model.modelId}`))
|
|
1010
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
if (mode === 'cline') {
|
|
1014
|
-
console.log(chalk.dim(` 📖 Cline configured with model: ${model.modelId}`))
|
|
1015
|
-
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
1016
|
-
}
|
|
1017
|
-
|
|
953
|
+
// 📖 Pre-launch hooks for tools that need special treatment
|
|
954
|
+
if (mode === 'hermes') restartHermesGateway()
|
|
1018
955
|
if (mode === 'xcode') {
|
|
1019
956
|
const xcodeUrl = launchPlan.baseUrl ? launchPlan.baseUrl.replace(/\/v1$/, '').replace(/\/v1\/chat\/completions$/, '') : ''
|
|
1020
957
|
console.log(chalk.bold.cyan('\n 🛠️ Xcode Intelligence Setup Instructions:'))
|
|
@@ -1027,29 +964,24 @@ export async function startExternalTool(mode, model, config) {
|
|
|
1027
964
|
console.log(chalk.dim(' Description: ') + chalk.green(`FCM - ${sources[model.providerKey]?.name || model.providerKey}`))
|
|
1028
965
|
console.log(chalk.white(` 4. Click Add, then select `) + chalk.bold(model.modelId) + chalk.white(` from the list.\n`))
|
|
1029
966
|
console.log(chalk.dim(` 📖 Attempting to launch Xcode...`))
|
|
1030
|
-
return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
|
|
1031
967
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
|
|
1054
|
-
return 1
|
|
968
|
+
if (mode === 'crush') console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
|
|
969
|
+
|
|
970
|
+
// 📖 Tool-specific info messages (only for modes that have no prepare-step message)
|
|
971
|
+
const infoMessages = {
|
|
972
|
+
openhands: ` 📖 OpenHands launched with model: ${model.modelId}`,
|
|
973
|
+
amp: ` 📖 Amp config updated with model: ${model.modelId}`,
|
|
974
|
+
hermes: ` 📖 Hermes Agent configured with model: ${model.modelId}`,
|
|
975
|
+
continue: ` 📖 Continue CLI configured with model: ${model.modelId}`,
|
|
976
|
+
cline: ` 📖 Cline configured with model: ${model.modelId}`,
|
|
977
|
+
caveman: ' 📖 Launching Caveman Code...',
|
|
978
|
+
jcode: ' 📖 Launching jcode...',
|
|
979
|
+
copilot: ` 📖 Copilot CLI configured with model: ${model.modelId}`,
|
|
980
|
+
forgecode: ` 📖 ForgeCode configured with model: ${model.modelId}`,
|
|
981
|
+
}
|
|
982
|
+
if (infoMessages[mode]) console.log(chalk.dim(infoMessages[mode]))
|
|
983
|
+
|
|
984
|
+
// 📖 xcode uses raw command ("open"), everything else resolves via tool-bootstrap
|
|
985
|
+
const command = mode === 'xcode' ? launchPlan.command : resolveLaunchCommand(mode, launchPlan.command)
|
|
986
|
+
return spawnCommand(command, launchPlan.args, launchPlan.env)
|
|
1055
987
|
}
|