free-coding-models 0.3.1 → 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 CHANGED
@@ -2,6 +2,24 @@
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
+
17
+ ## 0.3.2
18
+
19
+ ### Fixed
20
+ - **Claude Code model-family routing now mirrors `free-claude-code`**: The proxy remaps Claude's internal model ids like `claude-3-5-sonnet-*`, `claude-3-haiku-*`, `claude-3-opus-*`, `sonnet`, `haiku`, and `default` back to the selected FCM proxy model instead of rejecting them as missing.
21
+ - **Claude Code helper/background requests stay on the selected model**: Launches now pin the Anthropic helper model env vars and encode the selected proxy model inside `ANTHROPIC_AUTH_TOKEN`, so Claude Code has a stable fallback even when it emits internal aliases.
22
+
5
23
  ## 0.3.1
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -182,13 +182,13 @@ bunx free-coding-models YOUR_API_KEY
182
182
 
183
183
  ### 🆕 What's New
184
184
 
185
- **Version 0.3.1 tightens the proxy/tooling path and ships the missing diagnostics:**
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 proxy launches are cleaner** — FCM now launches Claude Code with an Anthropic-only proxy contract (`ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`) instead of mixing auth modes.
188
- - **Codex proxy launches now use the right API path** — Codex is forced into an explicit custom provider config and the proxy now implements `POST /v1/responses`.
189
- - **Gemini proxy launches fail fast when unsupported**Older Gemini CLI builds and invalid local config are detected up front, with a clear message instead of a misleading broken launch.
190
- - **Proxy auto-sync follows the current tool** — The FCM Proxy V2 overlay no longer relies on a separate active-tool picker, and `Y` now lists only stable persisted-config install targets.
191
- - **Beta messaging is explicit** — The README and runtime launcher diagnostics now call out that proxy-backed external tool support is still stabilizing.
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.
192
192
 
193
193
  ---
194
194
 
@@ -687,7 +687,7 @@ Press **Z** to cycle through all 13 tool modes in the TUI, or use flags to start
687
687
 
688
688
  Proxy-backed external tool support is still beta. Expect occasional launch/auth rough edges while third-party CLI contracts are still settling.
689
689
 
690
- `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.
691
691
 
692
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.
693
693
 
@@ -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
- hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true || config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
488
- scrollOffset: 0, // 📖 First visible model index in viewport
489
- terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
490
- terminalCols: process.stdout.columns || 80, // 📖 Current terminal width
491
- widthWarningStartedAt: (process.stdout.columns || 80) < 166 ? now : null, // 📖 Start the narrow-terminal countdown immediately when booting in a small viewport.
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.1",
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
 
@@ -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
- const updateRowIdx = providerKeys.length
963
- const proxyDaemonRowIdx = updateRowIdx + 1
964
- const changelogViewRowIdx = updateRowIdx + 2
965
- // 📖 Profile rows start after maintenance + proxy/daemon + changelog
966
- const savedProfiles = listProfiles(state.config)
967
- const profileStartIdx = updateRowIdx + 3
968
- const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : changelogViewRowIdx
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 : updateRowIdx
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', 'g': 'usage'
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
- // 📖 Phase 1: Check if background daemon is running delegate instead of starting in-process
579
- if (!forceRestart) {
580
- try {
581
- const daemonRunning = await isDaemonRunning()
582
- if (daemonRunning) {
583
- const info = getDaemonInfo()
584
- if (info) {
585
- return {
586
- port: info.port,
587
- accountCount: info.accountCount || 0,
588
- proxyToken: info.token,
589
- proxyModels: null,
590
- availableModelSlugs: new Set(), // 📖 daemon handles model discovery
591
- isDaemon: true,
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
- } catch { /* daemon check failed — fall through to in-process */ }
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
- const updateRowIdx = providerKeys.length
149
- const proxyDaemonRowIdx = updateRowIdx + 1
150
- const changelogViewRowIdx = updateRowIdx + 2
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
- const profileStartIdx = updateRowIdx + 3
308
- const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
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
- lines.push(` ${chalk.cyan('Usage')} Remaining quota for this exact provider when quota telemetry is exposed ${chalk.dim('Sort:')} ${chalk.yellow('G')}`)
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')}`)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file lib/proxy-server.js
3
3
  * @description Multi-account rotation proxy server with SSE streaming,
4
- * token stats tracking, and persistent request logging.
4
+ * token stats tracking, Anthropic/OpenAI translation, and persistent request logging.
5
5
  *
6
6
  * Design:
7
7
  * - Binds to 127.0.0.1 only (never 0.0.0.0)
@@ -10,6 +10,8 @@
10
10
  * - x-ratelimit-* headers are stripped from all responses forwarded to clients
11
11
  * - Retry loop: first attempt uses sticky session fingerprint; subsequent
12
12
  * retries use fresh P2C to avoid hitting the same failed account
13
+ * - Claude-family aliases are resolved inside the proxy so Claude Code can
14
+ * keep emitting `claude-*` / `sonnet` / `haiku` style model ids safely
13
15
  *
14
16
  * @exports ProxyServer
15
17
  */
@@ -106,6 +108,74 @@ function sendJson(res, statusCode, body) {
106
108
  res.end(json)
107
109
  }
108
110
 
111
+ function normalizeRequestedModel(modelId) {
112
+ if (typeof modelId !== 'string') return null
113
+ const trimmed = modelId.trim()
114
+ if (!trimmed) return null
115
+ return trimmed.replace(/^fcm-proxy\//, '')
116
+ }
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
+
127
+ function classifyClaudeVirtualModel(modelId) {
128
+ const normalized = normalizeRequestedModel(modelId)
129
+ if (!normalized) return null
130
+
131
+ const lower = normalized.toLowerCase()
132
+
133
+ // 📖 Mirror free-claude-code's family routing approach: classify by Claude
134
+ // 📖 family keywords, not only exact ids. Claude Code regularly emits both
135
+ // 📖 short aliases (`sonnet`) and full versioned ids (`claude-3-5-sonnet-*`).
136
+ if (lower === 'default') return 'default'
137
+ if (/^opus(?:plan)?(?:\[1m\])?$/.test(lower)) return 'opus'
138
+ if (/^sonnet(?:\[1m\])?$/.test(lower)) return 'sonnet'
139
+ if (lower === 'haiku') return 'haiku'
140
+ if (!lower.startsWith('claude-')) return null
141
+ if (lower.includes('opus')) return 'opus'
142
+ if (lower.includes('haiku')) return 'haiku'
143
+ if (lower.includes('sonnet')) return 'sonnet'
144
+ return null
145
+ }
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
+
163
+ function parseProxyAuthorizationHeader(authorization, expectedToken) {
164
+ if (!expectedToken) return { authorized: true, modelHint: null }
165
+ if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
166
+ return { authorized: false, modelHint: null }
167
+ }
168
+
169
+ const rawToken = authorization.slice('Bearer '.length).trim()
170
+ if (rawToken === expectedToken) return { authorized: true, modelHint: null }
171
+ if (!rawToken.startsWith(`${expectedToken}:`)) return { authorized: false, modelHint: null }
172
+
173
+ const modelHint = normalizeRequestedModel(rawToken.slice(expectedToken.length + 1))
174
+ return modelHint
175
+ ? { authorized: true, modelHint }
176
+ : { authorized: false, modelHint: null }
177
+ }
178
+
109
179
  // ─── ProxyServer ─────────────────────────────────────────────────────────────
110
180
 
111
181
  export class ProxyServer {
@@ -115,6 +185,7 @@ export class ProxyServer {
115
185
  * accounts?: Array<{ id: string, providerKey: string, apiKey: string, modelId: string, url: string }>,
116
186
  * retries?: number,
117
187
  * proxyApiKey?: string,
188
+ * anthropicRouting?: { model?: string|null, modelOpus?: string|null, modelSonnet?: string|null, modelHaiku?: string|null },
118
189
  * accountManagerOpts?: object,
119
190
  * tokenStatsOpts?: object,
120
191
  * thinkingConfig?: { mode: string, budget_tokens?: number },
@@ -127,6 +198,7 @@ export class ProxyServer {
127
198
  accounts = [],
128
199
  retries = 3,
129
200
  proxyApiKey = null,
201
+ anthropicRouting = null,
130
202
  accountManagerOpts = {},
131
203
  tokenStatsOpts = {},
132
204
  thinkingConfig,
@@ -138,6 +210,7 @@ export class ProxyServer {
138
210
  this._thinkingConfig = thinkingConfig
139
211
  this._compressionOpts = compressionOpts
140
212
  this._proxyApiKey = proxyApiKey
213
+ this._anthropicRouting = normalizeAnthropicRouting(anthropicRouting)
141
214
  this._accounts = accounts
142
215
  this._upstreamTimeoutMs = upstreamTimeoutMs
143
216
  // 📖 Progressive backoff delays (ms) for retries — first attempt is immediate,
@@ -191,14 +264,40 @@ export class ProxyServer {
191
264
  port: this._listeningPort,
192
265
  accountCount: this._accounts.length,
193
266
  healthByAccount: this._accountManager.getAllHealth(),
267
+ anthropicRouting: this._anthropicRouting,
194
268
  }
195
269
  }
196
270
 
271
+ _getAuthContext(req) {
272
+ return parseProxyAuthorizationHeader(req.headers.authorization, this._proxyApiKey)
273
+ }
274
+
197
275
  _isAuthorized(req) {
198
- if (!this._proxyApiKey) return true
199
- const authorization = req.headers.authorization
200
- if (typeof authorization !== 'string') return false
201
- return authorization === `Bearer ${this._proxyApiKey}`
276
+ return this._getAuthContext(req).authorized
277
+ }
278
+
279
+ _resolveAnthropicRequestedModel(modelId, authModelHint = null) {
280
+ const requestedModel = normalizeRequestedModel(modelId)
281
+ if (requestedModel && this._accountManager.hasAccountsForModel(requestedModel)) {
282
+ return requestedModel
283
+ }
284
+
285
+ const mappedModel = resolveAnthropicMappedModel(requestedModel, this._anthropicRouting)
286
+ if (mappedModel && this._accountManager.hasAccountsForModel(mappedModel)) {
287
+ return mappedModel
288
+ }
289
+
290
+ // 📖 Claude Code still emits internal aliases / tier model ids for some
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.
294
+ if (authModelHint && this._accountManager.hasAccountsForModel(authModelHint)) {
295
+ if (!requestedModel || classifyClaudeVirtualModel(requestedModel) || requestedModel.toLowerCase().startsWith('claude-')) {
296
+ return authModelHint
297
+ }
298
+ }
299
+
300
+ return requestedModel
202
301
  }
203
302
 
204
303
  // ── Request routing ────────────────────────────────────────────────────────
@@ -209,7 +308,8 @@ export class ProxyServer {
209
308
  return this._handleHealth(res)
210
309
  }
211
310
 
212
- if (!this._isAuthorized(req)) {
311
+ const authContext = this._getAuthContext(req)
312
+ if (!authContext.authorized) {
213
313
  return sendJson(res, 401, { error: 'Unauthorized' })
214
314
  }
215
315
 
@@ -227,7 +327,7 @@ export class ProxyServer {
227
327
  })
228
328
  } else if (req.method === 'POST' && req.url === '/v1/messages') {
229
329
  // 📖 Anthropic Messages API translation — enables Claude Code compatibility
230
- this._handleAnthropicMessages(req, res).catch(err => {
330
+ this._handleAnthropicMessages(req, res, authContext).catch(err => {
231
331
  console.error('[proxy] Internal error:', err)
232
332
  const status = err.statusCode === 413 ? 413 : 500
233
333
  const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
@@ -716,6 +816,7 @@ export class ProxyServer {
716
816
  byModel: summary.byModel || {},
717
817
  recentRequests: summary.recentRequests || [],
718
818
  },
819
+ anthropicRouting: this._anthropicRouting,
719
820
  totals: {
720
821
  requests: totalRequests,
721
822
  tokens: totalTokens,
@@ -733,7 +834,7 @@ export class ProxyServer {
733
834
  *
734
835
  * 📖 This makes Claude Code work natively through the FCM proxy.
735
836
  */
736
- async _handleAnthropicMessages(clientReq, clientRes) {
837
+ async _handleAnthropicMessages(clientReq, clientRes, authContext = { modelHint: null }) {
737
838
  const rawBody = await readBody(clientReq)
738
839
  let anthropicBody
739
840
  try {
@@ -744,6 +845,8 @@ export class ProxyServer {
744
845
 
745
846
  // 📖 Translate Anthropic → OpenAI
746
847
  const openaiBody = translateAnthropicToOpenAI(anthropicBody)
848
+ const resolvedModel = this._resolveAnthropicRequestedModel(openaiBody.model, authContext.modelHint)
849
+ if (resolvedModel) openaiBody.model = resolvedModel
747
850
  const isStreaming = openaiBody.stream === true
748
851
 
749
852
  if (isStreaming) {
@@ -1321,9 +1424,11 @@ export class ProxyServer {
1321
1424
  * 📖 In-flight requests on old accounts will finish naturally.
1322
1425
  *
1323
1426
  * @param {Array} accounts — new account list
1427
+ * @param {{ model?: string|null, modelOpus?: string|null, modelSonnet?: string|null, modelHaiku?: string|null }} anthropicRouting
1324
1428
  */
1325
- updateAccounts(accounts) {
1429
+ updateAccounts(accounts, anthropicRouting = this._anthropicRouting) {
1326
1430
  this._accounts = accounts
1431
+ this._anthropicRouting = normalizeAnthropicRouting(anthropicRouting)
1327
1432
  this._accountManager = new AccountManager(accounts, {})
1328
1433
  }
1329
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', 'claude-code', 'codex', 'openhands',
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 ANTHROPIC_API_KEY="${proxyInfo.token}"`)
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)
@@ -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 { accounts, proxyModels }
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
  /**
@@ -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 sorts on plain G, so the highlighted letter must stay in the visible header.
301
- const usageH_c = sortColumn === 'usage' ? chalk.bold.cyan(usageH.padEnd(W_USAGE)) : (() => {
302
- const plain = 'UsaGe'
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
- // 📖 Separator line
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 provider-scoped remaining quota when measurable,
567
- // 📖 otherwise a green dot to show "usable but not meaningfully quantifiable".
568
- let usageCell
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, Usage)
595
- const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell + ' ' + usageCell
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))
@@ -19,19 +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: uses ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN only (mirrors free-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
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
28
32
  * → `buildCodexProxyArgs` — force Codex into a proxy-backed custom provider config
29
33
  * → `inspectGeminiCliSupport` — detect whether the installed Gemini CLI can use proxy mode safely
30
34
  * → `writeGooseConfig` — install provider + set GOOSE_PROVIDER/GOOSE_MODEL in config.yaml
31
35
  * → `writeCrushConfig` — write provider + models.large/small to crush.json
32
36
  * → `startExternalTool` — configure and launch the selected external tool mode
33
37
  *
34
- * @exports resolveLauncherModelId, buildCodexProxyArgs, inspectGeminiCliSupport, startExternalTool
38
+ * @exports resolveLauncherModelId, waitForClaudeProxyRouting, buildClaudeProxyArgs, buildCodexProxyArgs
39
+ * @exports inspectGeminiCliSupport, startExternalTool
35
40
  *
36
41
  * @see src/tool-metadata.js
37
42
  * @see src/provider-metadata.js
@@ -45,7 +50,7 @@ import { delimiter, dirname, join } from 'path'
45
50
  import { spawn, spawnSync } from 'child_process'
46
51
  import { sources } from '../sources.js'
47
52
  import { PROVIDER_COLOR } from './render-table.js'
48
- import { getApiKey, getProxySettings } from './config.js'
53
+ import { getApiKey, getProxySettings, saveConfig, setClaudeProxyModelRouting } from './config.js'
49
54
  import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
50
55
  import { getToolMeta } from './tool-metadata.js'
51
56
  import { ensureProxyRunning, resolveProxyModelId } from './opencode.js'
@@ -65,6 +70,11 @@ const ANTHROPIC_ENV_KEYS = [
65
70
  'ANTHROPIC_AUTH_TOKEN',
66
71
  'ANTHROPIC_BASE_URL',
67
72
  'ANTHROPIC_MODEL',
73
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
74
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
75
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
76
+ 'ANTHROPIC_SMALL_FAST_MODEL',
77
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
68
78
  ]
69
79
  const GEMINI_ENV_KEYS = [
70
80
  'GEMINI_API_KEY',
@@ -75,6 +85,9 @@ const GEMINI_ENV_KEYS = [
75
85
  const PROXY_SANITIZED_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS, ...ANTHROPIC_ENV_KEYS, ...GEMINI_ENV_KEYS]
76
86
  const GEMINI_PROXY_MIN_VERSION = '0.34.0'
77
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'
78
91
 
79
92
  function ensureDir(filePath) {
80
93
  const dir = dirname(filePath)
@@ -146,6 +159,17 @@ export function resolveLauncherModelId(model, useProxy = false) {
146
159
  return model?.modelId ?? ''
147
160
  }
148
161
 
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]
171
+ }
172
+
149
173
  export function buildToolEnv(mode, model, config, options = {}) {
150
174
  const {
151
175
  sanitize = false,
@@ -156,7 +180,7 @@ export function buildToolEnv(mode, model, config, options = {}) {
156
180
  const providerKey = model.providerKey
157
181
  const providerUrl = sources[providerKey]?.url || ''
158
182
  const baseUrl = getProviderBaseUrl(providerKey)
159
- const apiKey = getApiKey(config, providerKey)
183
+ const apiKey = sanitize ? (config?.apiKeys?.[providerKey] ?? null) : getApiKey(config, providerKey)
160
184
  const env = cloneInheritedEnv(inheritedEnv, sanitize ? PROXY_SANITIZED_ENV_KEYS : [])
161
185
  const providerEnvName = ENV_VAR_NAMES[providerKey]
162
186
  if (includeProviderEnv && providerEnvName && apiKey) env[providerEnvName] = apiKey
@@ -176,7 +200,6 @@ export function buildToolEnv(mode, model, config, options = {}) {
176
200
  if (mode === 'claude-code' && apiKey && baseUrl) {
177
201
  env.ANTHROPIC_AUTH_TOKEN = apiKey
178
202
  env.ANTHROPIC_BASE_URL = baseUrl
179
- env.ANTHROPIC_MODEL = model.modelId
180
203
  }
181
204
 
182
205
  if (mode === 'gemini' && apiKey && baseUrl) {
@@ -188,6 +211,31 @@ export function buildToolEnv(mode, model, config, options = {}) {
188
211
  return { env, apiKey, baseUrl, providerUrl }
189
212
  }
190
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
+
191
239
  export function buildCodexProxyArgs(baseUrl) {
192
240
  return [
193
241
  '-c', 'model_provider="fcm_proxy"',
@@ -578,9 +626,8 @@ export async function startExternalTool(mode, model, config) {
578
626
  return spawnCommand('goose', [], env)
579
627
  }
580
628
 
581
- // 📖 Claude Code, Codex, and Gemini require the FCM Proxy V2 background service.
582
- // 📖 Without it, these tools cannot connect to the free providers (protocol mismatch / no direct support).
583
- 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') {
584
631
  if (!proxySettings.enabled) {
585
632
  console.log()
586
633
  console.log(chalk.red(` ✖ ${meta.label} requires FCM Proxy V2 to work with free providers.`))
@@ -599,21 +646,46 @@ export async function startExternalTool(mode, model, config) {
599
646
  }
600
647
 
601
648
  if (mode === 'claude-code') {
602
- // 📖 Claude Code needs Anthropic-compatible wire format (POST /v1/messages).
603
- // 📖 Mirror free-claude-code: one auth env only (`ANTHROPIC_AUTH_TOKEN`) plus base URL.
604
- const started = await ensureProxyRunning(config)
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 })
605
666
  const { env: proxyEnv } = buildToolEnv(mode, model, config, {
606
667
  sanitize: true,
607
668
  includeCompatDefaults: false,
608
669
  includeProviderEnv: false,
609
670
  })
610
671
  const proxyBase = `http://127.0.0.1:${started.port}`
611
- const launchModelId = resolveLauncherModelId(model, true)
672
+ const claudeProxyToken = `${started.proxyToken}:${launchModelId}`
612
673
  proxyEnv.ANTHROPIC_BASE_URL = proxyBase
613
- proxyEnv.ANTHROPIC_AUTH_TOKEN = started.proxyToken
614
- proxyEnv.ANTHROPIC_MODEL = launchModelId
615
- console.log(chalk.dim(` 📖 Claude Code routed through FCM proxy on :${started.port} (Anthropic translation enabled)`))
616
- return spawnCommand('claude', ['--model', launchModelId], proxyEnv)
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)
617
689
  }
618
690
 
619
691
  if (mode === 'codex') {