free-coding-models 0.3.2 → 0.3.3
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 +12 -0
- package/README.md +7 -6
- package/bin/fcm-proxy-daemon.js +5 -2
- package/bin/free-coding-models.js +7 -6
- package/package.json +1 -1
- package/src/config.js +61 -2
- package/src/endpoint-installer.js +0 -2
- package/src/key-handler.js +26 -9
- package/src/opencode.js +19 -19
- package/src/overlays.js +14 -7
- package/src/proxy-server.js +42 -6
- package/src/proxy-sync.js +5 -4
- package/src/proxy-topology.js +9 -4
- package/src/render-table.js +17 -37
- package/src/tool-launchers.js +81 -39
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
+
## 0.3.3
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Claude Code now uses the real `free-claude-code` proxy contract**: FCM stopped injecting proxy slugs into `claude --model` / `ANTHROPIC_MODEL` and now launches Claude Code with only `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`.
|
|
9
|
+
- **Claude routing is now proxy-side `MODEL` / `MODEL_*` mapping**: The selected FCM model is persisted into the proxy's Anthropic routing config and hot-reloaded by the daemon, so fake Claude model ids resolve to the chosen free backend exactly like `free-claude-code`.
|
|
10
|
+
- **Claude launch now forces a real Claude alias**: FCM starts Claude Code with `--model sonnet`, which overrides stale broken local selections like `gpt-oss-120b` that Claude rejects before the proxy is even contacted.
|
|
11
|
+
- **Claude proxy sync leftovers were removed**: Claude Code is no longer treated as a persisted proxy-sync target, avoiding stale `ANTHROPIC_MODEL=<fcm-slug>` env files that broke the integration.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Terminal width warning progress bar and toggle**: Added visual progress bar to the terminal width warning overlay and a Settings toggle “Disable Widths Warning” to permanently suppress it.
|
|
15
|
+
- **Config setting `disableWidthsWarning`**: New boolean setting stored in profile and global config, default false.
|
|
16
|
+
|
|
5
17
|
## 0.3.2
|
|
6
18
|
|
|
7
19
|
### Fixed
|
package/README.md
CHANGED
|
@@ -182,12 +182,13 @@ bunx free-coding-models YOUR_API_KEY
|
|
|
182
182
|
|
|
183
183
|
### 🆕 What's New
|
|
184
184
|
|
|
185
|
-
**Version 0.3.
|
|
185
|
+
**Version 0.3.3 switches Claude Code to the exact `free-claude-code` pattern instead of injecting FCM slugs into Claude itself:**
|
|
186
186
|
|
|
187
|
-
- **Claude Code
|
|
188
|
-
- **
|
|
189
|
-
- **Claude
|
|
190
|
-
- **
|
|
187
|
+
- **Claude Code is proxy-only now** — FCM no longer tries to make Claude Code “select” `gpt-oss-120b` or any other free model directly. Claude still speaks in Claude model ids, and the proxy picks the real backend.
|
|
188
|
+
- **Proxy-side `MODEL` / `MODEL_OPUS` / `MODEL_SONNET` / `MODEL_HAIKU` routing now drives Claude** — When you launch Claude Code from a selected FCM row, FCM writes the selected proxy slug into the proxy's Anthropic routing config, exactly like `free-claude-code`, then lets the daemon hot-reload it.
|
|
189
|
+
- **Claude no longer gets `--model <fcm-slug>` or `ANTHROPIC_MODEL=<fcm-slug>`** — the launcher now passes only `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`, which is the same clean client-side contract used by `free-claude-code`.
|
|
190
|
+
- **Claude is now forced onto a real Claude alias at launch** — FCM starts Claude Code with `--model sonnet`, so stale local values like `gpt-oss-120b` cannot fail client-side before the proxy even receives the request.
|
|
191
|
+
- **Claude sync/install leftovers were removed from the proxy path** — Claude Code is no longer treated like a persisted-config target for proxy sync; its integration is runtime-only, with fake Claude ids resolved by the proxy.
|
|
191
192
|
|
|
192
193
|
---
|
|
193
194
|
|
|
@@ -686,7 +687,7 @@ Press **Z** to cycle through all 13 tool modes in the TUI, or use flags to start
|
|
|
686
687
|
|
|
687
688
|
Proxy-backed external tool support is still beta. Expect occasional launch/auth rough edges while third-party CLI contracts are still settling.
|
|
688
689
|
|
|
689
|
-
`Codex` is launched through an explicit custom provider config so it stays in API-key mode through the proxy. `Gemini` proxy launches are version-gated: older builds like `0.33.0` are blocked with a clear diagnostic instead of being misconfigured silently.
|
|
690
|
+
`Claude Code` is launched with a real Claude alias (`--model sonnet`) while the proxy maps that fake Claude family back to your selected FCM backend, which avoids stale local `gpt-oss-*` selections breaking before the proxy is hit. `Codex` is launched through an explicit custom provider config so it stays in API-key mode through the proxy. `Gemini` proxy launches are version-gated: older builds like `0.33.0` are blocked with a clear diagnostic instead of being misconfigured silently.
|
|
690
691
|
|
|
691
692
|
The **Install Endpoints** flow (`Y` key) now targets only the tools with a stable persisted config contract. `Claude Code`, `Codex`, and `Gemini` stay launcher-only and should be started directly from FCM.
|
|
692
693
|
|
package/bin/fcm-proxy-daemon.js
CHANGED
|
@@ -88,7 +88,7 @@ async function main() {
|
|
|
88
88
|
const { sources } = await import('../sources.js')
|
|
89
89
|
|
|
90
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
|
|
91
|
+
let fcmConfig, proxySettings, mergedModels, accounts, proxyModels, anthropicRouting
|
|
92
92
|
try {
|
|
93
93
|
fcmConfig = loadConfig()
|
|
94
94
|
proxySettings = getProxySettings(fcmConfig)
|
|
@@ -113,6 +113,7 @@ async function main() {
|
|
|
113
113
|
const topology = buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources)
|
|
114
114
|
accounts = topology.accounts
|
|
115
115
|
proxyModels = topology.proxyModels
|
|
116
|
+
anthropicRouting = topology.anthropicRouting
|
|
116
117
|
} catch (err) {
|
|
117
118
|
logError(`Fatal: Failed to build initial topology: ${err.message}`)
|
|
118
119
|
process.exit(1)
|
|
@@ -130,6 +131,7 @@ async function main() {
|
|
|
130
131
|
port,
|
|
131
132
|
accounts,
|
|
132
133
|
proxyApiKey: token,
|
|
134
|
+
anthropicRouting,
|
|
133
135
|
})
|
|
134
136
|
|
|
135
137
|
try {
|
|
@@ -175,9 +177,10 @@ async function main() {
|
|
|
175
177
|
return
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
proxy.updateAccounts(newTopology.accounts)
|
|
180
|
+
proxy.updateAccounts(newTopology.accounts, newTopology.anthropicRouting)
|
|
179
181
|
accounts = newTopology.accounts
|
|
180
182
|
proxyModels = newTopology.proxyModels
|
|
183
|
+
anthropicRouting = newTopology.anthropicRouting
|
|
181
184
|
|
|
182
185
|
// 📖 Update status file
|
|
183
186
|
writeDaemonStatus({
|
|
@@ -484,11 +484,12 @@ async function main() {
|
|
|
484
484
|
mode, // 📖 'opencode' or 'openclaw' — controls Enter action
|
|
485
485
|
tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
|
|
486
486
|
originFilterMode: 0, // 📖 Index into ORIGIN_CYCLE (0=All, then providers)
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
487
|
+
hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true || config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
|
|
488
|
+
disableWidthsWarning: config.settings?.disableWidthsWarning ?? false, // 📖 Disable widths warning toggle (default off)
|
|
489
|
+
scrollOffset: 0, // 📖 First visible model index in viewport
|
|
490
|
+
terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
|
|
491
|
+
terminalCols: process.stdout.columns || 80, // 📖 Current terminal width
|
|
492
|
+
widthWarningStartedAt: (process.stdout.columns || 80) < 166 ? now : null, // 📖 Start the narrow-terminal countdown immediately when booting in a small viewport.
|
|
492
493
|
widthWarningDismissed: false, // 📖 Esc hides the narrow-terminal warning early for the current narrow-width session.
|
|
493
494
|
widthWarningShowCount: 0, // 📖 Counter for how many times the narrow-terminal warning has been shown (max 2 per session).
|
|
494
495
|
// 📖 Settings screen state (P key opens it)
|
|
@@ -575,7 +576,7 @@ async function main() {
|
|
|
575
576
|
const prevCols = state.terminalCols
|
|
576
577
|
state.terminalRows = process.stdout.rows || 24
|
|
577
578
|
state.terminalCols = process.stdout.columns || 80
|
|
578
|
-
if (state.terminalCols < 166) {
|
|
579
|
+
if (state.terminalCols < 166 && !state.disableWidthsWarning) {
|
|
579
580
|
if (prevCols >= 166 || state.widthWarningDismissed) {
|
|
580
581
|
state.widthWarningStartedAt = Date.now()
|
|
581
582
|
state.widthWarningDismissed = false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
package/src/config.js
CHANGED
|
@@ -100,12 +100,13 @@
|
|
|
100
100
|
* → setActiveProfile(config, name) — Set which profile is active (null to clear)
|
|
101
101
|
* → _emptyProfileSettings() — Default TUI settings for a profile
|
|
102
102
|
* → getProxySettings(config) — Return normalized proxy settings from config
|
|
103
|
+
* → setClaudeProxyModelRouting(config, modelId) — Mirror free-claude-code MODEL/MODEL_* routing onto one selected FCM model
|
|
103
104
|
* → normalizeEndpointInstalls(endpointInstalls) — Keep tracked endpoint installs stable across app versions
|
|
104
105
|
*
|
|
105
106
|
* @exports loadConfig, saveConfig, validateConfigFile, getApiKey, isProviderEnabled
|
|
106
107
|
* @exports addApiKey, removeApiKey, listApiKeys — multi-key management helpers
|
|
107
108
|
* @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
|
|
108
|
-
* @exports getActiveProfileName, setActiveProfile, getProxySettings, normalizeEndpointInstalls
|
|
109
|
+
* @exports getActiveProfileName, setActiveProfile, getProxySettings, setClaudeProxyModelRouting, normalizeEndpointInstalls
|
|
109
110
|
* @exports CONFIG_PATH — path to the JSON config file
|
|
110
111
|
*
|
|
111
112
|
* @see bin/free-coding-models.js — main CLI that uses these functions
|
|
@@ -645,6 +646,24 @@ export function _emptyProfileSettings() {
|
|
|
645
646
|
hideUnconfiguredModels: true, // 📖 true = default to providers that are actually configured
|
|
646
647
|
preferredToolMode: 'opencode', // 📖 remember the last Z-selected launcher across app restarts
|
|
647
648
|
proxy: normalizeProxySettings(),
|
|
649
|
+
disableWidthsWarning: false, // 📖 Disable widths warning (default off)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function normalizeAnthropicRouting(anthropicRouting = null) {
|
|
654
|
+
const normalizeModelId = (value) => {
|
|
655
|
+
if (typeof value !== 'string') return null
|
|
656
|
+
const trimmed = value.trim().replace(/^fcm-proxy\//, '')
|
|
657
|
+
return trimmed || null
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
// 📖 Mirror free-claude-code naming: MODEL is the fallback, and MODEL_* are
|
|
662
|
+
// 📖 Claude-family overrides. FCM currently pins all four to one selected model.
|
|
663
|
+
model: normalizeModelId(anthropicRouting?.model),
|
|
664
|
+
modelOpus: normalizeModelId(anthropicRouting?.modelOpus),
|
|
665
|
+
modelSonnet: normalizeModelId(anthropicRouting?.modelSonnet),
|
|
666
|
+
modelHaiku: normalizeModelId(anthropicRouting?.modelHaiku),
|
|
648
667
|
}
|
|
649
668
|
}
|
|
650
669
|
|
|
@@ -658,7 +677,7 @@ export function _emptyProfileSettings() {
|
|
|
658
677
|
* 📖 daemonConsent — ISO timestamp of when user consented to daemon install, or null.
|
|
659
678
|
*
|
|
660
679
|
* @param {object|undefined|null} proxy
|
|
661
|
-
* @returns {{ enabled: boolean, syncToOpenCode: boolean, preferredPort: number, stableToken: string, daemonEnabled: boolean, daemonConsent: string|null }}
|
|
680
|
+
* @returns {{ enabled: boolean, syncToOpenCode: boolean, preferredPort: number, stableToken: string, daemonEnabled: boolean, daemonConsent: string|null, anthropicRouting: { model: string|null, modelOpus: string|null, modelSonnet: string|null, modelHaiku: string|null } }}
|
|
662
681
|
*/
|
|
663
682
|
export function normalizeProxySettings(proxy = null) {
|
|
664
683
|
const preferredPort = Number.isInteger(proxy?.preferredPort) && proxy.preferredPort >= 0 && proxy.preferredPort <= 65535
|
|
@@ -679,6 +698,7 @@ export function normalizeProxySettings(proxy = null) {
|
|
|
679
698
|
daemonConsent: (typeof proxy?.daemonConsent === 'string' && proxy.daemonConsent.length > 0)
|
|
680
699
|
? proxy.daemonConsent
|
|
681
700
|
: null,
|
|
701
|
+
anthropicRouting: normalizeAnthropicRouting(proxy?.anthropicRouting),
|
|
682
702
|
// 📖 activeTool — legacy field kept only for backward compatibility.
|
|
683
703
|
// 📖 Runtime sync now follows the current Z-selected tool automatically.
|
|
684
704
|
activeTool: (typeof proxy?.activeTool === 'string' && proxy.activeTool.length > 0)
|
|
@@ -698,6 +718,44 @@ export function getProxySettings(config) {
|
|
|
698
718
|
return normalizeProxySettings(config?.settings?.proxy)
|
|
699
719
|
}
|
|
700
720
|
|
|
721
|
+
/**
|
|
722
|
+
* 📖 Persist the free-claude-code style MODEL / MODEL_OPUS / MODEL_SONNET /
|
|
723
|
+
* 📖 MODEL_HAIKU routing onto one selected proxy model. Claude Code itself then
|
|
724
|
+
* 📖 keeps speaking in fake Claude model ids while the proxy chooses the backend.
|
|
725
|
+
*
|
|
726
|
+
* @param {object} config
|
|
727
|
+
* @param {string} modelId
|
|
728
|
+
* @returns {boolean} true when the normalized proxy settings changed
|
|
729
|
+
*/
|
|
730
|
+
export function setClaudeProxyModelRouting(config, modelId) {
|
|
731
|
+
const normalizedModelId = typeof modelId === 'string' ? modelId.trim().replace(/^fcm-proxy\//, '') : ''
|
|
732
|
+
if (!normalizedModelId) return false
|
|
733
|
+
|
|
734
|
+
if (!config.settings || typeof config.settings !== 'object') config.settings = {}
|
|
735
|
+
|
|
736
|
+
const current = getProxySettings(config)
|
|
737
|
+
const nextAnthropicRouting = {
|
|
738
|
+
model: normalizedModelId,
|
|
739
|
+
modelOpus: normalizedModelId,
|
|
740
|
+
modelSonnet: normalizedModelId,
|
|
741
|
+
modelHaiku: normalizedModelId,
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const changed = current.enabled !== true
|
|
745
|
+
|| current.anthropicRouting.model !== nextAnthropicRouting.model
|
|
746
|
+
|| current.anthropicRouting.modelOpus !== nextAnthropicRouting.modelOpus
|
|
747
|
+
|| current.anthropicRouting.modelSonnet !== nextAnthropicRouting.modelSonnet
|
|
748
|
+
|| current.anthropicRouting.modelHaiku !== nextAnthropicRouting.modelHaiku
|
|
749
|
+
|
|
750
|
+
config.settings.proxy = {
|
|
751
|
+
...current,
|
|
752
|
+
enabled: true,
|
|
753
|
+
anthropicRouting: nextAnthropicRouting,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return changed
|
|
757
|
+
}
|
|
758
|
+
|
|
701
759
|
/**
|
|
702
760
|
* 📖 normalizeEndpointInstalls keeps the endpoint-install tracking list safe to replay.
|
|
703
761
|
*
|
|
@@ -852,6 +910,7 @@ function _emptyConfig() {
|
|
|
852
910
|
settings: {
|
|
853
911
|
hideUnconfiguredModels: true,
|
|
854
912
|
proxy: normalizeProxySettings(),
|
|
913
|
+
disableWidthsWarning: false, // 📖 Disable widths warning toggle (default off)
|
|
855
914
|
},
|
|
856
915
|
// 📖 Pinned favorites rendered at top of the table ("providerKey/modelId").
|
|
857
916
|
favorites: [],
|
|
@@ -531,11 +531,9 @@ function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode, paths, c
|
|
|
531
531
|
const proxyBase = effectiveBaseUrl.replace(/\/v1$/, '')
|
|
532
532
|
envLines.push(`export ANTHROPIC_AUTH_TOKEN="${effectiveApiKey}"`)
|
|
533
533
|
envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
|
|
534
|
-
envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
|
|
535
534
|
} else {
|
|
536
535
|
envLines.push(`export ANTHROPIC_AUTH_TOKEN="${effectiveApiKey}"`)
|
|
537
536
|
envLines.push(`export ANTHROPIC_BASE_URL="${effectiveBaseUrl}"`)
|
|
538
|
-
envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
|
|
539
537
|
}
|
|
540
538
|
}
|
|
541
539
|
|
package/src/key-handler.js
CHANGED
|
@@ -959,13 +959,14 @@ export function createKeyHandler(ctx) {
|
|
|
959
959
|
if (state.settingsOpen) {
|
|
960
960
|
const proxySettings = getProxySettings(state.config)
|
|
961
961
|
const providerKeys = Object.keys(sources)
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
962
|
+
const updateRowIdx = providerKeys.length
|
|
963
|
+
const widthWarningRowIdx = updateRowIdx + 1
|
|
964
|
+
const proxyDaemonRowIdx = widthWarningRowIdx + 1
|
|
965
|
+
const changelogViewRowIdx = proxyDaemonRowIdx + 1
|
|
966
|
+
// 📖 Profile rows start after maintenance + width warning + proxy/daemon + changelog
|
|
967
|
+
const savedProfiles = listProfiles(state.config)
|
|
968
|
+
const profileStartIdx = updateRowIdx + 5
|
|
969
|
+
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : changelogViewRowIdx
|
|
969
970
|
|
|
970
971
|
// 📖 Edit/Add-key mode: capture typed characters for the API key
|
|
971
972
|
if (state.settingsEditMode || state.settingsAddKeyMode) {
|
|
@@ -1098,6 +1099,14 @@ export function createKeyHandler(ctx) {
|
|
|
1098
1099
|
return
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
1102
|
+
// 📖 Widths Warning toggle (Enter to toggle)
|
|
1103
|
+
if (state.settingsCursor === widthWarningRowIdx) {
|
|
1104
|
+
if (!state.config.settings) state.config.settings = {}
|
|
1105
|
+
state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
|
|
1106
|
+
saveConfig(state.config)
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1101
1110
|
// 📖 Proxy & Daemon row: Enter → open dedicated overlay
|
|
1102
1111
|
if (state.settingsCursor === proxyDaemonRowIdx) {
|
|
1103
1112
|
state.settingsOpen = false
|
|
@@ -1161,7 +1170,15 @@ export function createKeyHandler(ctx) {
|
|
|
1161
1170
|
}
|
|
1162
1171
|
|
|
1163
1172
|
if (key.name === 'space') {
|
|
1173
|
+
// 📖 Exclude certain rows from space toggle
|
|
1164
1174
|
if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyDaemonRowIdx || state.settingsCursor === changelogViewRowIdx) return
|
|
1175
|
+
// 📖 Widths Warning toggle (disable/enable)
|
|
1176
|
+
if (state.settingsCursor === widthWarningRowIdx) {
|
|
1177
|
+
if (!state.config.settings) state.config.settings = {}
|
|
1178
|
+
state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
|
|
1179
|
+
saveConfig(state.config)
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1165
1182
|
// 📖 Profile rows don't respond to Space
|
|
1166
1183
|
if (state.settingsCursor >= profileStartIdx) return
|
|
1167
1184
|
|
|
@@ -1204,7 +1221,7 @@ export function createKeyHandler(ctx) {
|
|
|
1204
1221
|
saveConfig(state.config)
|
|
1205
1222
|
// 📖 Re-clamp cursor after deletion (profile list just got shorter)
|
|
1206
1223
|
const newProfiles = listProfiles(state.config)
|
|
1207
|
-
const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 :
|
|
1224
|
+
const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : changelogViewRowIdx
|
|
1208
1225
|
if (state.settingsCursor > newMaxRowIdx) {
|
|
1209
1226
|
state.settingsCursor = Math.max(0, newMaxRowIdx)
|
|
1210
1227
|
}
|
|
@@ -1715,7 +1732,7 @@ export function createKeyHandler(ctx) {
|
|
|
1715
1732
|
// 📖 Shift+R is reserved for reset view settings
|
|
1716
1733
|
const sortKeys = {
|
|
1717
1734
|
'r': 'rank', 'o': 'origin', 'm': 'model',
|
|
1718
|
-
'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
|
|
1735
|
+
'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
|
|
1719
1736
|
}
|
|
1720
1737
|
|
|
1721
1738
|
if (sortKeys[key.name] && !key.ctrl && !key.shift) {
|
package/src/opencode.js
CHANGED
|
@@ -575,25 +575,25 @@ export async function ensureProxyRunning(fcmConfig, { forceRestart = false } = {
|
|
|
575
575
|
throw new Error('Proxy mode is disabled in Settings')
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
-
// 📖
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
578
|
+
// 📖 Always prefer the background daemon when it is available. Launcher code
|
|
579
|
+
// 📖 can update config and let the daemon hot-reload, which is closer to the
|
|
580
|
+
// 📖 free-claude-code model than spinning up tool-specific local proxies.
|
|
581
|
+
try {
|
|
582
|
+
const daemonRunning = await isDaemonRunning()
|
|
583
|
+
if (daemonRunning) {
|
|
584
|
+
const info = getDaemonInfo()
|
|
585
|
+
if (info) {
|
|
586
|
+
return {
|
|
587
|
+
port: info.port,
|
|
588
|
+
accountCount: info.accountCount || 0,
|
|
589
|
+
proxyToken: info.token,
|
|
590
|
+
proxyModels: null,
|
|
591
|
+
availableModelSlugs: new Set(), // 📖 daemon handles model discovery
|
|
592
|
+
isDaemon: true,
|
|
593
593
|
}
|
|
594
594
|
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
595
|
+
}
|
|
596
|
+
} catch { /* daemon check failed — fall through to in-process */ }
|
|
597
597
|
|
|
598
598
|
if (forceRestart && activeProxy) {
|
|
599
599
|
await cleanupProxy()
|
|
@@ -613,7 +613,7 @@ export async function ensureProxyRunning(fcmConfig, { forceRestart = false } = {
|
|
|
613
613
|
}
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
const { accounts, proxyModels } = buildProxyTopologyFromConfig(fcmConfig)
|
|
616
|
+
const { accounts, proxyModels, anthropicRouting } = buildProxyTopologyFromConfig(fcmConfig)
|
|
617
617
|
if (accounts.length === 0) {
|
|
618
618
|
throw new Error('No API keys found for proxy-capable models')
|
|
619
619
|
}
|
|
@@ -622,7 +622,7 @@ export async function ensureProxyRunning(fcmConfig, { forceRestart = false } = {
|
|
|
622
622
|
const proxySettings = getProxySettings(fcmConfig)
|
|
623
623
|
const proxyToken = proxySettings.stableToken || `fcm_${randomUUID().replace(/-/g, '')}`
|
|
624
624
|
const preferredPort = Number.isInteger(proxySettings.preferredPort) ? proxySettings.preferredPort : 0
|
|
625
|
-
const proxy = new ProxyServer({ port: preferredPort, accounts, proxyApiKey: proxyToken })
|
|
625
|
+
const proxy = new ProxyServer({ port: preferredPort, accounts, proxyApiKey: proxyToken, anthropicRouting })
|
|
626
626
|
const { port } = await proxy.start()
|
|
627
627
|
activeProxy = proxy
|
|
628
628
|
setActiveProxy(activeProxy)
|
package/src/overlays.js
CHANGED
|
@@ -145,9 +145,10 @@ export function createOverlayRenderers(state, deps) {
|
|
|
145
145
|
// 📖 Key "T" in settings = test API key for selected provider.
|
|
146
146
|
function renderSettings() {
|
|
147
147
|
const providerKeys = Object.keys(sources)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
const updateRowIdx = providerKeys.length
|
|
149
|
+
const widthWarningRowIdx = updateRowIdx + 1
|
|
150
|
+
const proxyDaemonRowIdx = widthWarningRowIdx + 1
|
|
151
|
+
const changelogViewRowIdx = proxyDaemonRowIdx + 1
|
|
151
152
|
const proxySettings = getProxySettings(state.config)
|
|
152
153
|
const EL = '\x1b[K'
|
|
153
154
|
const lines = []
|
|
@@ -274,6 +275,13 @@ export function createOverlayRenderers(state, deps) {
|
|
|
274
275
|
const updateRow = `${updateBullet}${chalk.bold(updateActionLabel).padEnd(44)} ${updateStatus}`
|
|
275
276
|
cursorLineByRow[updateRowIdx] = lines.length
|
|
276
277
|
lines.push(updateCursor ? chalk.bgRgb(30, 30, 60)(updateRow) : updateRow)
|
|
278
|
+
// 📖 Widths Warning toggle row (disable widths warning)
|
|
279
|
+
const disableWidthsWarning = Boolean(state.config.settings?.disableWidthsWarning)
|
|
280
|
+
const widthWarningBullet = state.settingsCursor === widthWarningRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
281
|
+
const widthWarningStatus = disableWidthsWarning ? chalk.greenBright('DISABLED') : chalk.dim('enabled')
|
|
282
|
+
const widthWarningRow = `${widthWarningBullet}${chalk.bold('Disable Widths Warning').padEnd(44)} ${widthWarningStatus}`
|
|
283
|
+
cursorLineByRow[widthWarningRowIdx] = lines.length
|
|
284
|
+
lines.push(state.settingsCursor === widthWarningRowIdx ? chalk.bgRgb(30, 30, 60)(widthWarningRow) : widthWarningRow)
|
|
277
285
|
if (updateState === 'error' && state.settingsUpdateError) {
|
|
278
286
|
lines.push(chalk.red(` ${state.settingsUpdateError}`))
|
|
279
287
|
}
|
|
@@ -304,8 +312,8 @@ export function createOverlayRenderers(state, deps) {
|
|
|
304
312
|
|
|
305
313
|
// 📖 Profiles section — list saved profiles with active indicator + delete support
|
|
306
314
|
const savedProfiles = listProfiles(state.config)
|
|
307
|
-
|
|
308
|
-
|
|
315
|
+
const profileStartIdx = updateRowIdx + 5
|
|
316
|
+
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : changelogViewRowIdx
|
|
309
317
|
|
|
310
318
|
lines.push('')
|
|
311
319
|
lines.push(` ${chalk.bold('📋 Profiles')} ${chalk.dim(savedProfiles.length > 0 ? `(${savedProfiles.length} saved)` : '(none — press Shift+S in main view to save)')}`)
|
|
@@ -612,8 +620,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
612
620
|
lines.push(` ${chalk.cyan('Used')} Total prompt+completion tokens consumed in logs for this exact provider/model pair`)
|
|
613
621
|
lines.push(` ${chalk.dim('Loaded once at startup from request-log.jsonl. Displayed in K tokens, or M tokens above one million.')}`)
|
|
614
622
|
lines.push('')
|
|
615
|
-
|
|
616
|
-
lines.push(` ${chalk.dim('If a provider does not expose a trustworthy remaining %, the table shows a green dot instead of a fake number.')}`)
|
|
623
|
+
|
|
617
624
|
|
|
618
625
|
lines.push('')
|
|
619
626
|
lines.push(` ${chalk.bold('Main TUI')}`)
|
package/src/proxy-server.js
CHANGED
|
@@ -115,6 +115,15 @@ function normalizeRequestedModel(modelId) {
|
|
|
115
115
|
return trimmed.replace(/^fcm-proxy\//, '')
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
function normalizeAnthropicRouting(anthropicRouting = null) {
|
|
119
|
+
return {
|
|
120
|
+
model: normalizeRequestedModel(anthropicRouting?.model),
|
|
121
|
+
modelOpus: normalizeRequestedModel(anthropicRouting?.modelOpus),
|
|
122
|
+
modelSonnet: normalizeRequestedModel(anthropicRouting?.modelSonnet),
|
|
123
|
+
modelHaiku: normalizeRequestedModel(anthropicRouting?.modelHaiku),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
function classifyClaudeVirtualModel(modelId) {
|
|
119
128
|
const normalized = normalizeRequestedModel(modelId)
|
|
120
129
|
if (!normalized) return null
|
|
@@ -135,6 +144,22 @@ function classifyClaudeVirtualModel(modelId) {
|
|
|
135
144
|
return null
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
function resolveAnthropicMappedModel(modelId, anthropicRouting) {
|
|
148
|
+
const routing = normalizeAnthropicRouting(anthropicRouting)
|
|
149
|
+
const fallbackModel = routing.model
|
|
150
|
+
if (!fallbackModel && !routing.modelOpus && !routing.modelSonnet && !routing.modelHaiku) {
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const family = classifyClaudeVirtualModel(modelId)
|
|
155
|
+
if (family === 'opus') return routing.modelOpus || fallbackModel
|
|
156
|
+
if (family === 'sonnet') return routing.modelSonnet || fallbackModel
|
|
157
|
+
if (family === 'haiku') return routing.modelHaiku || fallbackModel
|
|
158
|
+
|
|
159
|
+
// 📖 free-claude-code falls back to MODEL for unknown Claude ids too.
|
|
160
|
+
return fallbackModel
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
function parseProxyAuthorizationHeader(authorization, expectedToken) {
|
|
139
164
|
if (!expectedToken) return { authorized: true, modelHint: null }
|
|
140
165
|
if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
|
|
@@ -160,6 +185,7 @@ export class ProxyServer {
|
|
|
160
185
|
* accounts?: Array<{ id: string, providerKey: string, apiKey: string, modelId: string, url: string }>,
|
|
161
186
|
* retries?: number,
|
|
162
187
|
* proxyApiKey?: string,
|
|
188
|
+
* anthropicRouting?: { model?: string|null, modelOpus?: string|null, modelSonnet?: string|null, modelHaiku?: string|null },
|
|
163
189
|
* accountManagerOpts?: object,
|
|
164
190
|
* tokenStatsOpts?: object,
|
|
165
191
|
* thinkingConfig?: { mode: string, budget_tokens?: number },
|
|
@@ -172,6 +198,7 @@ export class ProxyServer {
|
|
|
172
198
|
accounts = [],
|
|
173
199
|
retries = 3,
|
|
174
200
|
proxyApiKey = null,
|
|
201
|
+
anthropicRouting = null,
|
|
175
202
|
accountManagerOpts = {},
|
|
176
203
|
tokenStatsOpts = {},
|
|
177
204
|
thinkingConfig,
|
|
@@ -183,6 +210,7 @@ export class ProxyServer {
|
|
|
183
210
|
this._thinkingConfig = thinkingConfig
|
|
184
211
|
this._compressionOpts = compressionOpts
|
|
185
212
|
this._proxyApiKey = proxyApiKey
|
|
213
|
+
this._anthropicRouting = normalizeAnthropicRouting(anthropicRouting)
|
|
186
214
|
this._accounts = accounts
|
|
187
215
|
this._upstreamTimeoutMs = upstreamTimeoutMs
|
|
188
216
|
// 📖 Progressive backoff delays (ms) for retries — first attempt is immediate,
|
|
@@ -236,6 +264,7 @@ export class ProxyServer {
|
|
|
236
264
|
port: this._listeningPort,
|
|
237
265
|
accountCount: this._accounts.length,
|
|
238
266
|
healthByAccount: this._accountManager.getAllHealth(),
|
|
267
|
+
anthropicRouting: this._anthropicRouting,
|
|
239
268
|
}
|
|
240
269
|
}
|
|
241
270
|
|
|
@@ -253,13 +282,17 @@ export class ProxyServer {
|
|
|
253
282
|
return requestedModel
|
|
254
283
|
}
|
|
255
284
|
|
|
285
|
+
const mappedModel = resolveAnthropicMappedModel(requestedModel, this._anthropicRouting)
|
|
286
|
+
if (mappedModel && this._accountManager.hasAccountsForModel(mappedModel)) {
|
|
287
|
+
return mappedModel
|
|
288
|
+
}
|
|
289
|
+
|
|
256
290
|
// 📖 Claude Code still emits internal aliases / tier model ids for some
|
|
257
|
-
// 📖 background and helper paths.
|
|
258
|
-
// 📖
|
|
259
|
-
// 📖
|
|
260
|
-
// 📖 `claude-3-5-sonnet-20241022` behave the same as `sonnet`.
|
|
291
|
+
// 📖 background and helper paths. Keep the old auth-token hint as a final
|
|
292
|
+
// 📖 compatibility fallback for already-launched sessions, but the primary
|
|
293
|
+
// 📖 routing path is now the free-claude-code style proxy-side mapping above.
|
|
261
294
|
if (authModelHint && this._accountManager.hasAccountsForModel(authModelHint)) {
|
|
262
|
-
if (!requestedModel || classifyClaudeVirtualModel(requestedModel)) {
|
|
295
|
+
if (!requestedModel || classifyClaudeVirtualModel(requestedModel) || requestedModel.toLowerCase().startsWith('claude-')) {
|
|
263
296
|
return authModelHint
|
|
264
297
|
}
|
|
265
298
|
}
|
|
@@ -783,6 +816,7 @@ export class ProxyServer {
|
|
|
783
816
|
byModel: summary.byModel || {},
|
|
784
817
|
recentRequests: summary.recentRequests || [],
|
|
785
818
|
},
|
|
819
|
+
anthropicRouting: this._anthropicRouting,
|
|
786
820
|
totals: {
|
|
787
821
|
requests: totalRequests,
|
|
788
822
|
tokens: totalTokens,
|
|
@@ -1390,9 +1424,11 @@ export class ProxyServer {
|
|
|
1390
1424
|
* 📖 In-flight requests on old accounts will finish naturally.
|
|
1391
1425
|
*
|
|
1392
1426
|
* @param {Array} accounts — new account list
|
|
1427
|
+
* @param {{ model?: string|null, modelOpus?: string|null, modelSonnet?: string|null, modelHaiku?: string|null }} anthropicRouting
|
|
1393
1428
|
*/
|
|
1394
|
-
updateAccounts(accounts) {
|
|
1429
|
+
updateAccounts(accounts, anthropicRouting = this._anthropicRouting) {
|
|
1395
1430
|
this._accounts = accounts
|
|
1431
|
+
this._anthropicRouting = normalizeAnthropicRouting(anthropicRouting)
|
|
1396
1432
|
this._accountManager = new AccountManager(accounts, {})
|
|
1397
1433
|
}
|
|
1398
1434
|
}
|
package/src/proxy-sync.js
CHANGED
|
@@ -33,10 +33,12 @@ import { getToolMeta } from './tool-metadata.js'
|
|
|
33
33
|
const PROXY_PROVIDER_ID = 'fcm-proxy'
|
|
34
34
|
|
|
35
35
|
// 📖 Tools that support proxy sync (have base URL + API key config)
|
|
36
|
-
// 📖 Gemini is excluded — it only stores a model name, no URL/key fields
|
|
36
|
+
// 📖 Gemini is excluded — it only stores a model name, no URL/key fields.
|
|
37
|
+
// 📖 Claude Code is excluded too: its free-claude-code style integration is
|
|
38
|
+
// 📖 runtime-only now, with fake Claude ids handled by the proxy itself.
|
|
37
39
|
export const PROXY_SYNCABLE_TOOLS = [
|
|
38
40
|
'opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose', 'pi',
|
|
39
|
-
'aider', 'amp', 'qwen', '
|
|
41
|
+
'aider', 'amp', 'qwen', 'codex', 'openhands',
|
|
40
42
|
]
|
|
41
43
|
|
|
42
44
|
const PROXY_SYNCABLE_CANONICAL = new Set(PROXY_SYNCABLE_TOOLS.map(tool => tool === 'opencode-desktop' ? 'opencode' : tool))
|
|
@@ -341,9 +343,8 @@ function syncEnvTool(proxyInfo, mergedModels, toolMode) {
|
|
|
341
343
|
// 📖 Claude Code: Anthropic-specific env vars
|
|
342
344
|
if (toolMode === 'claude-code') {
|
|
343
345
|
const proxyBase = proxyInfo.baseUrl.replace(/\/v1$/, '')
|
|
344
|
-
envLines.push(`export
|
|
346
|
+
envLines.push(`export ANTHROPIC_AUTH_TOKEN="${proxyInfo.token}"`)
|
|
345
347
|
envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
|
|
346
|
-
envLines.push(`export ANTHROPIC_MODEL="${primarySlug}"`)
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
ensureDirFor(envFilePath)
|
package/src/proxy-topology.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* The proxy server uses these accounts for multi-key rotation and load balancing.
|
|
10
10
|
*
|
|
11
11
|
* @functions
|
|
12
|
-
* → buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources) — build accounts + proxyModels
|
|
12
|
+
* → buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources) — build accounts + proxyModels + Anthropic family routing
|
|
13
13
|
* → buildMergedModelsForDaemon() — standalone helper to build merged models without TUI
|
|
14
14
|
*
|
|
15
15
|
* @exports buildProxyTopologyFromConfig, buildMergedModelsForDaemon
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* @see bin/fcm-proxy-daemon.js — standalone daemon that uses this directly
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { resolveApiKeys } from './config.js'
|
|
20
|
+
import { resolveApiKeys, getProxySettings } from './config.js'
|
|
21
21
|
import { resolveCloudflareUrl } from './ping.js'
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -29,7 +29,7 @@ import { resolveCloudflareUrl } from './ping.js'
|
|
|
29
29
|
* @param {object} fcmConfig — live config from loadConfig()
|
|
30
30
|
* @param {Array} mergedModels — output of buildMergedModels(MODELS)
|
|
31
31
|
* @param {object} sourcesMap — the sources object keyed by providerKey
|
|
32
|
-
* @returns {{ accounts: Array, proxyModels: Record<string, { name: string }
|
|
32
|
+
* @returns {{ accounts: Array, proxyModels: Record<string, { name: string }>, anthropicRouting: { model: string|null, modelOpus: string|null, modelSonnet: string|null, modelHaiku: string|null } }}
|
|
33
33
|
*/
|
|
34
34
|
export function buildProxyTopologyFromConfig(fcmConfig, mergedModels, sourcesMap) {
|
|
35
35
|
const accounts = []
|
|
@@ -64,7 +64,12 @@ export function buildProxyTopologyFromConfig(fcmConfig, mergedModels, sourcesMap
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
accounts,
|
|
69
|
+
proxyModels,
|
|
70
|
+
// 📖 Mirror free-claude-code: proxy-side Claude family routing is config-driven.
|
|
71
|
+
anthropicRouting: getProxySettings(fcmConfig).anthropicRouting,
|
|
72
|
+
}
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
/**
|
package/src/render-table.js
CHANGED
|
@@ -93,7 +93,7 @@ export function setActiveProxy(proxyInstance) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
|
|
96
|
-
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, startupLatestVersion = null, versionAlertsEnabled = true) {
|
|
96
|
+
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, startupLatestVersion = null, versionAlertsEnabled = true, disableWidthsWarning = false) {
|
|
97
97
|
// 📖 Filter out hidden models for display
|
|
98
98
|
const visibleResults = results.filter(r => !r.hidden)
|
|
99
99
|
|
|
@@ -192,12 +192,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
192
192
|
const W_STAB = 11
|
|
193
193
|
const W_UPTIME = 6
|
|
194
194
|
const W_TOKENS = 7
|
|
195
|
-
const W_USAGE = 7
|
|
195
|
+
// const W_USAGE = 7 // Usage column removed
|
|
196
196
|
const MIN_TABLE_WIDTH = 166
|
|
197
197
|
const warningDurationMs = 4_000
|
|
198
198
|
const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
|
|
199
199
|
const remainingMs = Math.max(0, warningDurationMs - elapsed)
|
|
200
|
-
const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && widthWarningShowCount < 2 && remainingMs > 0
|
|
200
|
+
const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !disableWidthsWarning && !widthWarningDismissed && widthWarningShowCount < 2 && remainingMs > 0
|
|
201
201
|
|
|
202
202
|
if (showWidthWarning) {
|
|
203
203
|
const lines = []
|
|
@@ -216,6 +216,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
216
216
|
lines.push(' '.repeat(padLeft3) + chalk.red(warning3))
|
|
217
217
|
lines.push('')
|
|
218
218
|
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + chalk.yellow(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
|
|
219
|
+
const barTotal = Math.max(0, Math.min(terminalCols - 4, 30))
|
|
220
|
+
const barFill = Math.round((elapsed / warningDurationMs) * barTotal)
|
|
221
|
+
const barStr = chalk.green('█'.repeat(barFill)) + chalk.dim('░'.repeat(barTotal - barFill))
|
|
222
|
+
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - barTotal) / 2))) + barStr)
|
|
219
223
|
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 20) / 2))) + chalk.dim('press esc to dismiss'))
|
|
220
224
|
while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
|
|
221
225
|
const EL = '\x1b[K'
|
|
@@ -297,17 +301,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
297
301
|
return chalk.yellow.bold('U') + chalk.dim('p%' + padding)
|
|
298
302
|
})()
|
|
299
303
|
const tokensH_c = chalk.dim(tokensH.padEnd(W_TOKENS))
|
|
300
|
-
// 📖 Usage
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const padding = ' '.repeat(Math.max(0, W_USAGE - plain.length))
|
|
304
|
-
return chalk.dim('Usa') + chalk.yellow.bold('G') + chalk.dim('e' + padding)
|
|
305
|
-
})()
|
|
306
|
-
|
|
307
|
-
// 📖 Header with proper spacing (column order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used, Usage)
|
|
308
|
-
lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + stabH_c + ' ' + uptimeH_c + ' ' + tokensH_c + ' ' + usageH_c)
|
|
304
|
+
// 📖 Usage column removed from UI – no header or separator for it.
|
|
305
|
+
// Header without Usage column (column order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used)
|
|
306
|
+
lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + stabH_c + ' ' + uptimeH_c + ' ' + tokensH_c)
|
|
309
307
|
|
|
310
|
-
//
|
|
308
|
+
// Separator line without Usage column
|
|
311
309
|
lines.push(
|
|
312
310
|
' ' +
|
|
313
311
|
chalk.dim('─'.repeat(W_RANK)) + ' ' +
|
|
@@ -322,8 +320,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
322
320
|
chalk.dim('─'.repeat(W_VERDICT)) + ' ' +
|
|
323
321
|
chalk.dim('─'.repeat(W_STAB)) + ' ' +
|
|
324
322
|
chalk.dim('─'.repeat(W_UPTIME)) + ' ' +
|
|
325
|
-
chalk.dim('─'.repeat(W_TOKENS))
|
|
326
|
-
chalk.dim('─'.repeat(W_USAGE))
|
|
323
|
+
chalk.dim('─'.repeat(W_TOKENS))
|
|
327
324
|
)
|
|
328
325
|
|
|
329
326
|
if (sorted.length === 0) {
|
|
@@ -563,26 +560,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
563
560
|
const sourceCursorText = providerName.padEnd(W_SOURCE)
|
|
564
561
|
const sourceCell = isCursor ? chalk.rgb(...providerRgb).bold(sourceCursorText) : source
|
|
565
562
|
|
|
566
|
-
// 📖 Usage column
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
if (r.usagePercent !== undefined && r.usagePercent !== null) {
|
|
570
|
-
const usageStr = Math.round(r.usagePercent) + '%'
|
|
571
|
-
if (r.usagePercent >= 80) {
|
|
572
|
-
usageCell = chalk.greenBright(usageStr.padEnd(W_USAGE))
|
|
573
|
-
} else if (r.usagePercent >= 50) {
|
|
574
|
-
usageCell = chalk.yellow(usageStr.padEnd(W_USAGE))
|
|
575
|
-
} else if (r.usagePercent >= 20) {
|
|
576
|
-
usageCell = chalk.rgb(255, 165, 0)(usageStr.padEnd(W_USAGE)) // orange
|
|
577
|
-
} else {
|
|
578
|
-
usageCell = chalk.red(usageStr.padEnd(W_USAGE))
|
|
579
|
-
}
|
|
580
|
-
} else {
|
|
581
|
-
const usagePlaceholder = usagePlaceholderForProvider(r.providerKey)
|
|
582
|
-
usageCell = usagePlaceholder === '🟢'
|
|
583
|
-
? chalk.greenBright(usagePlaceholder.padEnd(W_USAGE))
|
|
584
|
-
: chalk.dim(usagePlaceholder.padEnd(W_USAGE))
|
|
585
|
-
}
|
|
563
|
+
// 📖 Usage column removed from UI – no usage data displayed.
|
|
564
|
+
// (We keep the logic but do not render it.)
|
|
565
|
+
const usageCell = ''
|
|
586
566
|
|
|
587
567
|
// 📖 Used column — total historical prompt+completion tokens consumed for this
|
|
588
568
|
// 📖 exact provider/model pair, loaded once from request-log.jsonl at startup.
|
|
@@ -591,8 +571,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
591
571
|
? chalk.rgb(120, 210, 255)(formatTokenTotalCompact(tokenTotal).padEnd(W_TOKENS))
|
|
592
572
|
: chalk.dim('0'.padEnd(W_TOKENS))
|
|
593
573
|
|
|
594
|
-
// 📖 Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used
|
|
595
|
-
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell
|
|
574
|
+
// 📖 Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used)
|
|
575
|
+
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell
|
|
596
576
|
|
|
597
577
|
if (isCursor) {
|
|
598
578
|
lines.push(chalk.bgRgb(155, 55, 135)(row))
|
package/src/tool-launchers.js
CHANGED
|
@@ -19,22 +19,24 @@
|
|
|
19
19
|
* 📖 Crush: writes crush.json with provider config + models.large/small defaults
|
|
20
20
|
* 📖 Pi: uses --provider/--model CLI flags for guaranteed auto-selection
|
|
21
21
|
* 📖 Aider: writes ~/.aider.conf.yml + passes --model flag
|
|
22
|
-
* 📖 Claude Code:
|
|
22
|
+
* 📖 Claude Code: mirrors free-claude-code by keeping fake Claude model ids on the client,
|
|
23
|
+
* forcing a valid Claude alias at launch, and moving MODEL / MODEL_OPUS / MODEL_SONNET /
|
|
24
|
+
* MODEL_HAIKU routing into the proxy
|
|
23
25
|
* 📖 Codex CLI: uses a custom model_provider override so Codex stays in explicit API-provider mode
|
|
24
26
|
* 📖 Gemini CLI: proxy mode is capability-gated because older builds do not support custom base URL routing cleanly
|
|
25
27
|
*
|
|
26
28
|
* @functions
|
|
27
29
|
* → `resolveLauncherModelId` — choose the provider-specific id or proxy slug for a launch
|
|
28
|
-
* → `
|
|
29
|
-
* → `
|
|
30
|
+
* → `waitForClaudeProxyRouting` — wait until the daemon/proxy has reloaded the free-claude-code style Claude-family mapping
|
|
31
|
+
* → `buildClaudeProxyArgs` — force a valid Claude alias so stale local non-Claude selections cannot break launch
|
|
30
32
|
* → `buildCodexProxyArgs` — force Codex into a proxy-backed custom provider config
|
|
31
33
|
* → `inspectGeminiCliSupport` — detect whether the installed Gemini CLI can use proxy mode safely
|
|
32
34
|
* → `writeGooseConfig` — install provider + set GOOSE_PROVIDER/GOOSE_MODEL in config.yaml
|
|
33
35
|
* → `writeCrushConfig` — write provider + models.large/small to crush.json
|
|
34
36
|
* → `startExternalTool` — configure and launch the selected external tool mode
|
|
35
37
|
*
|
|
36
|
-
* @exports resolveLauncherModelId,
|
|
37
|
-
* @exports
|
|
38
|
+
* @exports resolveLauncherModelId, waitForClaudeProxyRouting, buildClaudeProxyArgs, buildCodexProxyArgs
|
|
39
|
+
* @exports inspectGeminiCliSupport, startExternalTool
|
|
38
40
|
*
|
|
39
41
|
* @see src/tool-metadata.js
|
|
40
42
|
* @see src/provider-metadata.js
|
|
@@ -48,7 +50,7 @@ import { delimiter, dirname, join } from 'path'
|
|
|
48
50
|
import { spawn, spawnSync } from 'child_process'
|
|
49
51
|
import { sources } from '../sources.js'
|
|
50
52
|
import { PROVIDER_COLOR } from './render-table.js'
|
|
51
|
-
import { getApiKey, getProxySettings } from './config.js'
|
|
53
|
+
import { getApiKey, getProxySettings, saveConfig, setClaudeProxyModelRouting } from './config.js'
|
|
52
54
|
import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
|
|
53
55
|
import { getToolMeta } from './tool-metadata.js'
|
|
54
56
|
import { ensureProxyRunning, resolveProxyModelId } from './opencode.js'
|
|
@@ -83,6 +85,9 @@ const GEMINI_ENV_KEYS = [
|
|
|
83
85
|
const PROXY_SANITIZED_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS, ...ANTHROPIC_ENV_KEYS, ...GEMINI_ENV_KEYS]
|
|
84
86
|
const GEMINI_PROXY_MIN_VERSION = '0.34.0'
|
|
85
87
|
const EXPERIMENTAL_PROXY_TOOLS_NOTE = 'FCM Proxy V2 support for external tools is still in beta, so some launch and authentication flows can remain flaky while the integration stabilizes.'
|
|
88
|
+
const CLAUDE_PROXY_RELOAD_TIMEOUT_MS = 4000
|
|
89
|
+
const CLAUDE_PROXY_RELOAD_INTERVAL_MS = 200
|
|
90
|
+
const CLAUDE_PROXY_CLIENT_MODEL = 'sonnet'
|
|
86
91
|
|
|
87
92
|
function ensureDir(filePath) {
|
|
88
93
|
const dir = dirname(filePath)
|
|
@@ -154,26 +159,15 @@ export function resolveLauncherModelId(model, useProxy = false) {
|
|
|
154
159
|
return model?.modelId ?? ''
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolvedModelId
|
|
167
|
-
env.ANTHROPIC_SMALL_FAST_MODEL = resolvedModelId
|
|
168
|
-
env.CLAUDE_CODE_SUBAGENT_MODEL = resolvedModelId
|
|
169
|
-
return env
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function buildClaudeProxyAuthToken(proxyToken, modelId) {
|
|
173
|
-
const resolvedProxyToken = typeof proxyToken === 'string' ? proxyToken.trim() : ''
|
|
174
|
-
const resolvedModelId = typeof modelId === 'string' ? modelId.trim() : ''
|
|
175
|
-
if (!resolvedProxyToken) return ''
|
|
176
|
-
return resolvedModelId ? `${resolvedProxyToken}:${resolvedModelId}` : resolvedProxyToken
|
|
162
|
+
/**
|
|
163
|
+
* 📖 Force Claude Code to start on a real Claude alias, never on an FCM slug.
|
|
164
|
+
* 📖 Older FCM launches poisoned Claude's local model state with `gpt-oss-*`,
|
|
165
|
+
* 📖 and Claude rejects those client-side before any proxy request is made.
|
|
166
|
+
*
|
|
167
|
+
* @returns {string[]}
|
|
168
|
+
*/
|
|
169
|
+
export function buildClaudeProxyArgs() {
|
|
170
|
+
return ['--model', CLAUDE_PROXY_CLIENT_MODEL]
|
|
177
171
|
}
|
|
178
172
|
|
|
179
173
|
export function buildToolEnv(mode, model, config, options = {}) {
|
|
@@ -186,7 +180,7 @@ export function buildToolEnv(mode, model, config, options = {}) {
|
|
|
186
180
|
const providerKey = model.providerKey
|
|
187
181
|
const providerUrl = sources[providerKey]?.url || ''
|
|
188
182
|
const baseUrl = getProviderBaseUrl(providerKey)
|
|
189
|
-
const apiKey = getApiKey(config, providerKey)
|
|
183
|
+
const apiKey = sanitize ? (config?.apiKeys?.[providerKey] ?? null) : getApiKey(config, providerKey)
|
|
190
184
|
const env = cloneInheritedEnv(inheritedEnv, sanitize ? PROXY_SANITIZED_ENV_KEYS : [])
|
|
191
185
|
const providerEnvName = ENV_VAR_NAMES[providerKey]
|
|
192
186
|
if (includeProviderEnv && providerEnvName && apiKey) env[providerEnvName] = apiKey
|
|
@@ -206,7 +200,6 @@ export function buildToolEnv(mode, model, config, options = {}) {
|
|
|
206
200
|
if (mode === 'claude-code' && apiKey && baseUrl) {
|
|
207
201
|
env.ANTHROPIC_AUTH_TOKEN = apiKey
|
|
208
202
|
env.ANTHROPIC_BASE_URL = baseUrl
|
|
209
|
-
env.ANTHROPIC_MODEL = model.modelId
|
|
210
203
|
}
|
|
211
204
|
|
|
212
205
|
if (mode === 'gemini' && apiKey && baseUrl) {
|
|
@@ -218,6 +211,31 @@ export function buildToolEnv(mode, model, config, options = {}) {
|
|
|
218
211
|
return { env, apiKey, baseUrl, providerUrl }
|
|
219
212
|
}
|
|
220
213
|
|
|
214
|
+
export async function waitForClaudeProxyRouting(port, token, expectedModelId) {
|
|
215
|
+
const expected = typeof expectedModelId === 'string' ? expectedModelId.trim().replace(/^fcm-proxy\//, '') : ''
|
|
216
|
+
if (!expected || !port || !token) return false
|
|
217
|
+
|
|
218
|
+
const deadline = Date.now() + CLAUDE_PROXY_RELOAD_TIMEOUT_MS
|
|
219
|
+
while (Date.now() < deadline) {
|
|
220
|
+
try {
|
|
221
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/stats`, {
|
|
222
|
+
headers: { authorization: `Bearer ${token}` },
|
|
223
|
+
})
|
|
224
|
+
if (res.ok) {
|
|
225
|
+
const payload = await res.json()
|
|
226
|
+
const active = payload?.anthropicRouting?.model
|
|
227
|
+
if (typeof active === 'string' && active.replace(/^fcm-proxy\//, '') === expected) {
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch { /* daemon may still be reloading — keep polling */ }
|
|
232
|
+
|
|
233
|
+
await new Promise(resolve => setTimeout(resolve, CLAUDE_PROXY_RELOAD_INTERVAL_MS))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
|
|
221
239
|
export function buildCodexProxyArgs(baseUrl) {
|
|
222
240
|
return [
|
|
223
241
|
'-c', 'model_provider="fcm_proxy"',
|
|
@@ -608,9 +626,8 @@ export async function startExternalTool(mode, model, config) {
|
|
|
608
626
|
return spawnCommand('goose', [], env)
|
|
609
627
|
}
|
|
610
628
|
|
|
611
|
-
// 📖
|
|
612
|
-
|
|
613
|
-
if (mode === 'claude-code' || mode === 'codex' || mode === 'gemini') {
|
|
629
|
+
// 📖 Codex and Gemini require FCM Proxy V2 to talk to the free-provider mesh.
|
|
630
|
+
if (mode === 'codex' || mode === 'gemini') {
|
|
614
631
|
if (!proxySettings.enabled) {
|
|
615
632
|
console.log()
|
|
616
633
|
console.log(chalk.red(` ✖ ${meta.label} requires FCM Proxy V2 to work with free providers.`))
|
|
@@ -629,21 +646,46 @@ export async function startExternalTool(mode, model, config) {
|
|
|
629
646
|
}
|
|
630
647
|
|
|
631
648
|
if (mode === 'claude-code') {
|
|
632
|
-
// 📖
|
|
633
|
-
// 📖
|
|
634
|
-
|
|
649
|
+
// 📖 Mirror free-claude-code exactly on the client side:
|
|
650
|
+
// 📖 Claude gets only ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN, and the
|
|
651
|
+
// 📖 proxy owns the fake Claude model ids -> real backend model mapping.
|
|
652
|
+
const launchModelId = resolveLauncherModelId(model, true)
|
|
653
|
+
const routingChanged = setClaudeProxyModelRouting(config, launchModelId)
|
|
654
|
+
if (routingChanged) {
|
|
655
|
+
const saveResult = saveConfig(config)
|
|
656
|
+
if (!saveResult.success) {
|
|
657
|
+
console.log()
|
|
658
|
+
console.log(chalk.red(' ✖ Failed to persist the Claude proxy routing before launch.'))
|
|
659
|
+
console.log(chalk.dim(` ${saveResult.error || 'Unknown config write error.'}`))
|
|
660
|
+
console.log()
|
|
661
|
+
return 1
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const started = await ensureProxyRunning(config, { forceRestart: true })
|
|
635
666
|
const { env: proxyEnv } = buildToolEnv(mode, model, config, {
|
|
636
667
|
sanitize: true,
|
|
637
668
|
includeCompatDefaults: false,
|
|
638
669
|
includeProviderEnv: false,
|
|
639
670
|
})
|
|
640
671
|
const proxyBase = `http://127.0.0.1:${started.port}`
|
|
641
|
-
const
|
|
672
|
+
const claudeProxyToken = `${started.proxyToken}:${launchModelId}`
|
|
642
673
|
proxyEnv.ANTHROPIC_BASE_URL = proxyBase
|
|
643
|
-
proxyEnv.ANTHROPIC_AUTH_TOKEN =
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
674
|
+
proxyEnv.ANTHROPIC_AUTH_TOKEN = claudeProxyToken
|
|
675
|
+
|
|
676
|
+
const routingReady = await waitForClaudeProxyRouting(started.port, started.proxyToken, launchModelId)
|
|
677
|
+
if (!routingReady) {
|
|
678
|
+
console.log(chalk.yellow(` ⚠ Claude proxy routing reload is taking longer than expected; launching anyway.`))
|
|
679
|
+
console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (routingChanged && proxySettings.enabled !== true) {
|
|
683
|
+
console.log(chalk.dim(' 📖 Proxy mode was auto-enabled for Claude Code because this integration is proxy-only.'))
|
|
684
|
+
}
|
|
685
|
+
console.log(chalk.dim(` 📖 Claude Code routed through FCM proxy on :${started.port} with proxy-side Claude model mapping`))
|
|
686
|
+
console.log(chalk.dim(` 📖 Claude itself is forced onto the safe alias: ${CLAUDE_PROXY_CLIENT_MODEL}`))
|
|
687
|
+
console.log(chalk.dim(` 📖 All Claude families now resolve to: ${model.label} (${launchModelId})`))
|
|
688
|
+
return spawnCommand('claude', buildClaudeProxyArgs(), proxyEnv)
|
|
647
689
|
}
|
|
648
690
|
|
|
649
691
|
if (mode === 'codex') {
|