free-coding-models 0.3.2 → 0.3.4

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,28 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 0.3.4
6
+
7
+ ### Added
8
+ - **Proxy root landing JSON**: `GET /` on FCM Proxy V2 now returns a small unauthenticated status payload, so browser checks no longer fail with `{"error":"Unauthorized"}`.
9
+ - **`daemon stop` CLI command**: The public CLI now supports `free-coding-models daemon stop`, matching the existing daemon manager capability and the documented workflow.
10
+
11
+ ### Fixed
12
+ - **README/UI parity restored**: The docs now match the current product surface, including `160` models, the `Used` token-history column, and the current launcher/proxy behavior.
13
+ - **Malformed config sections are normalized on load**: Invalid `apiKeys`, `providers`, or `settings` values are now coerced back to safe empty objects instead of leaking broken runtime shapes into the app.
14
+
15
+ ## 0.3.3
16
+
17
+ ### Fixed
18
+ - **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`.
19
+ - **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`.
20
+ - **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.
21
+ - **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.
22
+
23
+ ### Added
24
+ - **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.
25
+ - **Config setting `disableWidthsWarning`**: New boolean setting stored in profile and global config, default false.
26
+
5
27
  ## 0.3.2
6
28
 
7
29
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <img src="https://img.shields.io/npm/v/free-coding-models?color=76b900&label=npm&logo=npm" alt="npm version">
3
3
  <img src="https://img.shields.io/node/v/free-coding-models?color=76b900&logo=node.js" alt="node version">
4
4
  <img src="https://img.shields.io/npm/l/free-coding-models?color=76b900" alt="license">
5
- <img src="https://img.shields.io/badge/models-159-76b900?logo=nvidia" alt="models count">
5
+ <img src="https://img.shields.io/badge/models-160-76b900?logo=nvidia" alt="models count">
6
6
  <img src="https://img.shields.io/badge/providers-20-blue" alt="providers count">
7
7
  </p>
8
8
 
@@ -81,7 +81,7 @@ By Vanessa Depraute
81
81
  - **📈 Rolling averages** — Avg calculated from ALL successful pings since start
82
82
  - **📊 Uptime tracking** — Percentage of successful pings shown in real-time
83
83
  - **📐 Stability score** — Composite 0–100 score measuring consistency (p95, jitter, spikes, uptime)
84
- - **📊 Usage tracking** — Monitor remaining quota for each exact provider/model pair when the provider exposes it; otherwise the TUI shows a green dot instead of a misleading percentage.
84
+ - **📊 Token usage tracking** — The proxy logs prompt+completion token usage per exact provider/model pair, and the TUI surfaces that history in the `Used` column and the request log overlay.
85
85
  - **📜 Request Log Overlay** — Press `X` to inspect recent proxied requests and token usage for exact provider/model pairs.
86
86
  - **📋 Changelog Overlay** — Press `N` to browse all versions in an index, then `Enter` to view details for any version with full scroll support
87
87
  - **🛠 MODEL_NOT_FOUND Rotation** — If a specific provider returns a 404 for a model, the TUI intelligently rotates through other available providers for the same model.
@@ -89,7 +89,7 @@ By Vanessa Depraute
89
89
  - **🎮 Interactive selection** — Navigate with arrow keys directly in the table, press Enter to act
90
90
  - **💻 OpenCode integration** — Auto-detects NIM setup, sets model as default, launches OpenCode
91
91
  - **🦞 OpenClaw integration** — Sets selected model as default provider in `~/.openclaw/openclaw.json`
92
- - **🧰 Public tool launchers** — `Enter` auto-configures and launches 10+ tools: `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, `Goose`, `Aider`, `Claude Code`, `Codex`, `Gemini`, `Qwen`, `OpenHands`, `Amp`, and `Pi`. All tools auto-select the chosen model on launch.
92
+ - **🧰 Public tool launchers** — `Enter` auto-configures and launches all 13 tool modes: `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, `Goose`, `Aider`, `Claude Code`, `Codex`, `Gemini`, `Qwen`, `OpenHands`, `Amp`, and `Pi`. All tools auto-select the chosen model on launch.
93
93
  - **🔌 Install Endpoints flow** — Press `Y` to install one configured provider into the compatible persisted-config tools, with a choice between **Direct Provider** (pure API) or **FCM Proxy V2** (key rotation + usage tracking), then pick all models or a curated subset
94
94
  - **📝 Feature Request (J key)** — Send anonymous feedback directly to the project team
95
95
  - **🐛 Bug Report (I key)** — Send anonymous bug reports directly to the project team
@@ -182,12 +182,12 @@ bunx free-coding-models YOUR_API_KEY
182
182
 
183
183
  ### 🆕 What's New
184
184
 
185
- **Version 0.3.2 hardens the Claude Code proxy path to match the routing strategy that works in `free-claude-code`:**
185
+ **Version 0.3.4 cleans up the public proxy/docs surface and ships a small stability pass:**
186
186
 
187
- - **Claude Code family-model routing is now proxy-side** — FCM remaps Claude's internal family ids such as `claude-3-5-sonnet-*`, `claude-3-haiku-*`, `claude-3-opus-*`, `sonnet`, `haiku`, and `default` back to the selected FCM proxy model.
188
- - **Claude Code helper model slots are pinned** — FCM now exports the selected model into `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, and `CLAUDE_CODE_SUBAGENT_MODEL` so background/helper requests stop drifting.
189
- - **Claude proxy auth now carries the selected model hint** — The launcher encodes the chosen proxy model into `ANTHROPIC_AUTH_TOKEN`, giving the proxy a reliable fallback even when Claude Code ignores the visible `/model` selection.
190
- - **Proxy support remains beta** External-tool proxy support is still stabilizing, but Claude Code should now behave much closer to the working `free-claude-code` setup.
187
+ - **Browser hits on the proxy root are now friendly** — `GET /` returns a small status JSON instead of `{"error":"Unauthorized"}` when you sanity-check the proxy in a browser.
188
+ - **`daemon stop` is now a real public CLI command** — the help text, the README, and the command parser all agree on the same daemon control surface.
189
+ - **The README now matches the current UI exactly** — model count is `160`, the `Used` column is documented correctly, and the removed `Usage` column is no longer described.
190
+ - **Malformed config sections are normalized safely on load** corrupted `apiKeys`, `providers`, or `settings` values no longer leak through as broken runtime objects.
191
191
 
192
192
  ---
193
193
 
@@ -245,7 +245,7 @@ Running `free-coding-models` with no launcher flag starts in **OpenCode CLI** mo
245
245
  - The active target is always visible in the header badge before you press `Enter`
246
246
 
247
247
  **How it works:**
248
- 1. **Ping phase** — All enabled models are pinged in parallel (up to 159 across 20 providers)
248
+ 1. **Ping phase** — All enabled models are pinged in parallel (up to 160 across 20 providers)
249
249
  2. **Continuous monitoring** — Models start at 2s re-pings for 60s, then fall back to 10s automatically, and slow to 30s after 5 minutes idle unless you force 4s mode with `W`
250
250
  3. **Real-time updates** — Watch "Latest", "Avg", and "Up%" columns update live
251
251
  4. **Select anytime** — Use ↑↓ arrows to navigate, press Enter on a model to act
@@ -428,7 +428,7 @@ TOGETHER_API_KEY=together_xxx free-coding-models
428
428
 
429
429
  ## 🤖 Coding Models
430
430
 
431
- **159 coding models** across 20 providers and 8 tiers, ranked by [SWE-bench Verified](https://www.swebench.com) — the industry-standard benchmark measuring real GitHub issue resolution. Scores are self-reported by providers unless noted.
431
+ **160 coding models** across 20 providers and 8 tiers, ranked by [SWE-bench Verified](https://www.swebench.com) — the industry-standard benchmark measuring real GitHub issue resolution. Scores are self-reported by providers unless noted.
432
432
 
433
433
  ### Alibaba Cloud (DashScope) (8 models)
434
434
 
@@ -512,7 +512,6 @@ The main table displays one row per model with the following columns:
512
512
  | **Stability** | `B` | Composite 0–100 consistency score (see [Stability Score](#-stability-score)) |
513
513
  | **Up%** | `U` | Uptime — percentage of successful pings |
514
514
  | **Used** | — | Total prompt+completion tokens consumed in logs for this exact provider/model pair, shown in `k` or `M` |
515
- | **Usage** | `G` | Provider-scoped quota remaining when measurable; otherwise a green dot means usage % is not applicable/reliable for that provider |
516
515
 
517
516
  ### Verdict values
518
517
 
@@ -601,6 +600,8 @@ free-coding-models daemon uninstall # Remove OS service completely
601
600
  free-coding-models daemon logs # Show recent service logs
602
601
  ```
603
602
 
603
+ For a quick browser sanity-check, open [http://127.0.0.1:18045/](http://127.0.0.1:18045/) or [http://127.0.0.1:18045/v1/health](http://127.0.0.1:18045/v1/health) while the proxy is running.
604
+
604
605
  ### Service management
605
606
 
606
607
  The dedicated **FCM Proxy V2** overlay (accessible via `J` from main TUI, or Settings → Enter) provides full control:
@@ -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
 
@@ -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({
@@ -271,6 +271,18 @@ async function main() {
271
271
  console.log()
272
272
  process.exit(result.success ? 0 : 1)
273
273
  }
274
+ if (daemonSubcmd === 'stop') {
275
+ const result = dm.stopDaemon()
276
+ console.log()
277
+ if (result.success) {
278
+ console.log(chalk.greenBright(' ✅ FCM Proxy V2 service stopped.'))
279
+ console.log(chalk.dim(' The service stays installed and can be restarted later.'))
280
+ } else {
281
+ console.log(chalk.red(` ❌ Stop failed: ${result.error}`))
282
+ }
283
+ console.log()
284
+ process.exit(result.success ? 0 : 1)
285
+ }
274
286
  if (daemonSubcmd === 'logs') {
275
287
  const logPath = dm.getDaemonLogPath()
276
288
  console.log(chalk.dim(` Log file: ${logPath}`))
@@ -283,7 +295,7 @@ async function main() {
283
295
  process.exit(0)
284
296
  }
285
297
  console.log(chalk.red(` Unknown command: ${daemonSubcmd}`))
286
- console.log(chalk.dim(' Usage: free-coding-models daemon [status|install|uninstall|restart|logs]'))
298
+ console.log(chalk.dim(' Usage: free-coding-models daemon [status|install|uninstall|restart|stop|logs]'))
287
299
  process.exit(1)
288
300
  }
289
301
 
@@ -484,11 +496,12 @@ async function main() {
484
496
  mode, // 📖 'opencode' or 'openclaw' — controls Enter action
485
497
  tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
486
498
  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.
499
+ hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true || config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
500
+ disableWidthsWarning: config.settings?.disableWidthsWarning ?? false, // 📖 Disable widths warning toggle (default off)
501
+ scrollOffset: 0, // 📖 First visible model index in viewport
502
+ terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
503
+ terminalCols: process.stdout.columns || 80, // 📖 Current terminal width
504
+ widthWarningStartedAt: (process.stdout.columns || 80) < 166 ? now : null, // 📖 Start the narrow-terminal countdown immediately when booting in a small viewport.
492
505
  widthWarningDismissed: false, // 📖 Esc hides the narrow-terminal warning early for the current narrow-width session.
493
506
  widthWarningShowCount: 0, // 📖 Counter for how many times the narrow-terminal warning has been shown (max 2 per session).
494
507
  // 📖 Settings screen state (P key opens it)
@@ -575,7 +588,7 @@ async function main() {
575
588
  const prevCols = state.terminalCols
576
589
  state.terminalRows = process.stdout.rows || 24
577
590
  state.terminalCols = process.stdout.columns || 80
578
- if (state.terminalCols < 166) {
591
+ if (state.terminalCols < 166 && !state.disableWidthsWarning) {
579
592
  if (prevCols >= 166 || state.widthWarningDismissed) {
580
593
  state.widthWarningStartedAt = Date.now()
581
594
  state.widthWarningDismissed = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",
@@ -4,7 +4,7 @@
4
4
  * and OpenAI Chat Completions API.
5
5
  *
6
6
  * 📖 This is the key module that enables Claude Code to work natively through the
7
- * FCM proxy without needing the external "free-claude-code" Python proxy.
7
+ * FCM proxy without needing the external Claude proxy integration.
8
8
  * Claude Code sends requests in Anthropic format (POST /v1/messages) and this
9
9
  * module translates them to OpenAI format for the upstream providers, then
10
10
  * translates the responses back.
package/src/cli-help.js CHANGED
@@ -40,6 +40,7 @@ const COMMANDS = [
40
40
  { command: 'daemon install', description: 'Install and start the background service' },
41
41
  { command: 'daemon uninstall', description: 'Remove the background service' },
42
42
  { command: 'daemon restart', description: 'Restart the background service' },
43
+ { command: 'daemon stop', description: 'Gracefully stop the background service without uninstalling it' },
43
44
  { command: 'daemon logs', description: 'Print the latest daemon log lines' },
44
45
  ]
45
46
 
@@ -70,7 +71,7 @@ export function buildCliHelpLines({ chalk = null, indent = '', title = 'CLI Help
70
71
 
71
72
  lines.push(`${indent}${paint(chalk, chalk?.bold, title)}`)
72
73
  lines.push(`${indent}${paint(chalk, chalk?.dim, 'Usage: free-coding-models [apiKey] [options]')}`)
73
- lines.push(`${indent}${paint(chalk, chalk?.dim, ' free-coding-models daemon [status|install|uninstall|restart|logs]')}`)
74
+ lines.push(`${indent}${paint(chalk, chalk?.dim, ' free-coding-models daemon [status|install|uninstall|restart|stop|logs]')}`)
74
75
  lines.push('')
75
76
  lines.push(`${indent}${paint(chalk, chalk?.bold, 'Tool Flags')}`)
76
77
  for (const entry of launchFlags) {
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
@@ -182,10 +183,10 @@ export function loadConfig() {
182
183
  try {
183
184
  const raw = readFileSync(CONFIG_PATH, 'utf8').trim()
184
185
  const parsed = JSON.parse(raw)
185
- // 📖 Ensure the shape is always complete — fill missing sections with defaults
186
- if (!parsed.apiKeys) parsed.apiKeys = {}
187
- if (!parsed.providers) parsed.providers = {}
188
- if (!parsed.settings || typeof parsed.settings !== 'object') parsed.settings = {}
186
+ // 📖 Ensure the shape is always complete — fill missing or corrupted sections with defaults.
187
+ if (!parsed.apiKeys || typeof parsed.apiKeys !== 'object' || Array.isArray(parsed.apiKeys)) parsed.apiKeys = {}
188
+ if (!parsed.providers || typeof parsed.providers !== 'object' || Array.isArray(parsed.providers)) parsed.providers = {}
189
+ if (!parsed.settings || typeof parsed.settings !== 'object' || Array.isArray(parsed.settings)) parsed.settings = {}
189
190
  if (typeof parsed.settings.hideUnconfiguredModels !== 'boolean') parsed.settings.hideUnconfiguredModels = true
190
191
  parsed.settings.proxy = normalizeProxySettings(parsed.settings.proxy)
191
192
  // 📖 Favorites: list of "providerKey/modelId" pinned rows.
@@ -442,9 +443,11 @@ export function validateConfigFile(options = {}) {
442
443
  throw new Error('Config is not a valid object')
443
444
  }
444
445
 
445
- // 📖 Check for critical corruption (apiKeys should be an object if it exists)
446
- if (parsed.apiKeys !== null && parsed.apiKeys !== undefined && typeof parsed.apiKeys !== 'object') {
447
- throw new Error('apiKeys field is corrupted')
446
+ // 📖 Check for critical corruption (apiKeys should be an object if it exists).
447
+ // 📖 Treat this as recoverable loadConfig() will normalize the value safely.
448
+ if (parsed.apiKeys !== null && parsed.apiKeys !== undefined
449
+ && (typeof parsed.apiKeys !== 'object' || Array.isArray(parsed.apiKeys))) {
450
+ console.warn('⚠️ apiKeys field malformed; it will be normalized on load')
448
451
  }
449
452
 
450
453
  return { valid: true }
@@ -645,6 +648,24 @@ export function _emptyProfileSettings() {
645
648
  hideUnconfiguredModels: true, // 📖 true = default to providers that are actually configured
646
649
  preferredToolMode: 'opencode', // 📖 remember the last Z-selected launcher across app restarts
647
650
  proxy: normalizeProxySettings(),
651
+ disableWidthsWarning: false, // 📖 Disable widths warning (default off)
652
+ }
653
+ }
654
+
655
+ function normalizeAnthropicRouting(anthropicRouting = null) {
656
+ const normalizeModelId = (value) => {
657
+ if (typeof value !== 'string') return null
658
+ const trimmed = value.trim().replace(/^fcm-proxy\//, '')
659
+ return trimmed || null
660
+ }
661
+
662
+ return {
663
+ // 📖 Mirror free-claude-code naming: MODEL is the fallback, and MODEL_* are
664
+ // 📖 Claude-family overrides. FCM currently pins all four to one selected model.
665
+ model: normalizeModelId(anthropicRouting?.model),
666
+ modelOpus: normalizeModelId(anthropicRouting?.modelOpus),
667
+ modelSonnet: normalizeModelId(anthropicRouting?.modelSonnet),
668
+ modelHaiku: normalizeModelId(anthropicRouting?.modelHaiku),
648
669
  }
649
670
  }
650
671
 
@@ -658,7 +679,7 @@ export function _emptyProfileSettings() {
658
679
  * 📖 daemonConsent — ISO timestamp of when user consented to daemon install, or null.
659
680
  *
660
681
  * @param {object|undefined|null} proxy
661
- * @returns {{ enabled: boolean, syncToOpenCode: boolean, preferredPort: number, stableToken: string, daemonEnabled: boolean, daemonConsent: string|null }}
682
+ * @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
683
  */
663
684
  export function normalizeProxySettings(proxy = null) {
664
685
  const preferredPort = Number.isInteger(proxy?.preferredPort) && proxy.preferredPort >= 0 && proxy.preferredPort <= 65535
@@ -679,6 +700,7 @@ export function normalizeProxySettings(proxy = null) {
679
700
  daemonConsent: (typeof proxy?.daemonConsent === 'string' && proxy.daemonConsent.length > 0)
680
701
  ? proxy.daemonConsent
681
702
  : null,
703
+ anthropicRouting: normalizeAnthropicRouting(proxy?.anthropicRouting),
682
704
  // 📖 activeTool — legacy field kept only for backward compatibility.
683
705
  // 📖 Runtime sync now follows the current Z-selected tool automatically.
684
706
  activeTool: (typeof proxy?.activeTool === 'string' && proxy.activeTool.length > 0)
@@ -698,6 +720,44 @@ export function getProxySettings(config) {
698
720
  return normalizeProxySettings(config?.settings?.proxy)
699
721
  }
700
722
 
723
+ /**
724
+ * 📖 Persist the free-claude-code style MODEL / MODEL_OPUS / MODEL_SONNET /
725
+ * 📖 MODEL_HAIKU routing onto one selected proxy model. Claude Code itself then
726
+ * 📖 keeps speaking in fake Claude model ids while the proxy chooses the backend.
727
+ *
728
+ * @param {object} config
729
+ * @param {string} modelId
730
+ * @returns {boolean} true when the normalized proxy settings changed
731
+ */
732
+ export function setClaudeProxyModelRouting(config, modelId) {
733
+ const normalizedModelId = typeof modelId === 'string' ? modelId.trim().replace(/^fcm-proxy\//, '') : ''
734
+ if (!normalizedModelId) return false
735
+
736
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
737
+
738
+ const current = getProxySettings(config)
739
+ const nextAnthropicRouting = {
740
+ model: normalizedModelId,
741
+ modelOpus: normalizedModelId,
742
+ modelSonnet: normalizedModelId,
743
+ modelHaiku: normalizedModelId,
744
+ }
745
+
746
+ const changed = current.enabled !== true
747
+ || current.anthropicRouting.model !== nextAnthropicRouting.model
748
+ || current.anthropicRouting.modelOpus !== nextAnthropicRouting.modelOpus
749
+ || current.anthropicRouting.modelSonnet !== nextAnthropicRouting.modelSonnet
750
+ || current.anthropicRouting.modelHaiku !== nextAnthropicRouting.modelHaiku
751
+
752
+ config.settings.proxy = {
753
+ ...current,
754
+ enabled: true,
755
+ anthropicRouting: nextAnthropicRouting,
756
+ }
757
+
758
+ return changed
759
+ }
760
+
701
761
  /**
702
762
  * 📖 normalizeEndpointInstalls keeps the endpoint-install tracking list safe to replay.
703
763
  *
@@ -852,6 +912,7 @@ function _emptyConfig() {
852
912
  settings: {
853
913
  hideUnconfiguredModels: true,
854
914
  proxy: normalizeProxySettings(),
915
+ disableWidthsWarning: false, // 📖 Disable widths warning toggle (default off)
855
916
  },
856
917
  // 📖 Pinned favorites rendered at top of the table ("providerKey/modelId").
857
918
  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
+ // 📖 Claude proxy 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')}`)
@@ -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. When the launcher encoded the selected
258
- // 📖 proxy slug into the auth token, remap those virtual Claude ids here.
259
- // 📖 This intentionally matches Claude families by substring so ids like
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
  }
@@ -270,6 +303,12 @@ export class ProxyServer {
270
303
  // ── Request routing ────────────────────────────────────────────────────────
271
304
 
272
305
  _handleRequest(req, res) {
306
+ // 📖 Root endpoint is unauthenticated so a browser hit on http://127.0.0.1:{port}/
307
+ // 📖 gives a useful status payload instead of a misleading Unauthorized error.
308
+ if (req.method === 'GET' && req.url === '/') {
309
+ return this._handleRoot(res)
310
+ }
311
+
273
312
  // 📖 Health endpoint is unauthenticated so external monitors can probe it
274
313
  if (req.method === 'GET' && req.url === '/v1/health') {
275
314
  return this._handleHealth(res)
@@ -747,6 +786,26 @@ export class ProxyServer {
747
786
 
748
787
  // ── GET /v1/health ──────────────────────────────────────────────────────────
749
788
 
789
+ /**
790
+ * 📖 Friendly unauthenticated landing endpoint for browsers and quick local checks.
791
+ */
792
+ _handleRoot(res) {
793
+ const status = this.getStatus()
794
+ const uniqueModels = new Set(this._accounts.map(acct => acct.proxyModelId || acct.modelId)).size
795
+ sendJson(res, 200, {
796
+ status: 'ok',
797
+ service: 'fcm-proxy-v2',
798
+ running: status.running,
799
+ accountCount: status.accountCount,
800
+ modelCount: uniqueModels,
801
+ endpoints: {
802
+ health: '/v1/health',
803
+ models: '/v1/models',
804
+ stats: '/v1/stats',
805
+ },
806
+ })
807
+ }
808
+
750
809
  /**
751
810
  * 📖 Health endpoint for daemon liveness checks. Unauthenticated so external
752
811
  * monitors (TUI, launchctl, systemd) can probe without needing the token.
@@ -783,6 +842,7 @@ export class ProxyServer {
783
842
  byModel: summary.byModel || {},
784
843
  recentRequests: summary.recentRequests || [],
785
844
  },
845
+ anthropicRouting: this._anthropicRouting,
786
846
  totals: {
787
847
  requests: totalRequests,
788
848
  tokens: totalTokens,
@@ -1390,9 +1450,11 @@ export class ProxyServer {
1390
1450
  * 📖 In-flight requests on old accounts will finish naturally.
1391
1451
  *
1392
1452
  * @param {Array} accounts — new account list
1453
+ * @param {{ model?: string|null, modelOpus?: string|null, modelSonnet?: string|null, modelHaiku?: string|null }} anthropicRouting
1393
1454
  */
1394
- updateAccounts(accounts) {
1455
+ updateAccounts(accounts, anthropicRouting = this._anthropicRouting) {
1395
1456
  this._accounts = accounts
1457
+ this._anthropicRouting = normalizeAnthropicRouting(anthropicRouting)
1396
1458
  this._accountManager = new AccountManager(accounts, {})
1397
1459
  }
1398
1460
  }
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 proxy 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 Claude proxy: 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,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: uses ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN only (mirrors free-claude-code)
22
+ * 📖 Claude Code: mirrors Claude proxy 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
- * → `applyClaudeCodeModelOverrides` — force Claude Code auxiliary model slots onto the chosen proxy model
29
- * → `buildClaudeProxyAuthToken` — encode the proxy token + selected model hint for Claude-only fallback routing
30
+ * → `waitForClaudeProxyRouting` — wait until the daemon/proxy has reloaded the Claude proxy 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, applyClaudeCodeModelOverrides, buildClaudeProxyAuthToken
37
- * @exports buildCodexProxyArgs, inspectGeminiCliSupport, startExternalTool
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
- export function applyClaudeCodeModelOverrides(env, modelId) {
158
- const resolvedModelId = typeof modelId === 'string' ? modelId.trim() : ''
159
- if (!resolvedModelId) return env
160
-
161
- // 📖 Claude Code still uses auxiliary model slots (opus/sonnet/haiku/subagents)
162
- // 📖 even when a custom primary model is selected. Pin them all to the same slug.
163
- env.ANTHROPIC_MODEL = resolvedModelId
164
- env.ANTHROPIC_DEFAULT_OPUS_MODEL = resolvedModelId
165
- env.ANTHROPIC_DEFAULT_SONNET_MODEL = resolvedModelId
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
- // 📖 Claude Code, Codex, and Gemini require the FCM Proxy V2 background service.
612
- // 📖 Without it, these tools cannot connect to the free providers (protocol mismatch / no direct support).
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
- // 📖 Claude Code needs Anthropic-compatible wire format (POST /v1/messages).
633
- // 📖 Mirror free-claude-code: one auth env only (`ANTHROPIC_AUTH_TOKEN`) plus base URL.
634
- const started = await ensureProxyRunning(config)
649
+ // 📖 Mirror Claude proxy 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 launchModelId = resolveLauncherModelId(model, true)
672
+ const claudeProxyToken = `${started.proxyToken}:${launchModelId}`
642
673
  proxyEnv.ANTHROPIC_BASE_URL = proxyBase
643
- proxyEnv.ANTHROPIC_AUTH_TOKEN = buildClaudeProxyAuthToken(started.proxyToken, launchModelId)
644
- applyClaudeCodeModelOverrides(proxyEnv, launchModelId)
645
- console.log(chalk.dim(` 📖 Claude Code routed through FCM proxy on :${started.port} (Anthropic translation enabled)`))
646
- 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)
647
689
  }
648
690
 
649
691
  if (mode === 'codex') {