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 +22 -0
- package/README.md +13 -12
- package/bin/fcm-proxy-daemon.js +5 -2
- package/bin/free-coding-models.js +20 -7
- package/package.json +1 -1
- package/src/anthropic-translator.js +1 -1
- package/src/cli-help.js +2 -1
- package/src/config.js +70 -9
- package/src/endpoint-installer.js +0 -2
- package/src/key-handler.js +26 -9
- package/src/opencode.js +19 -19
- package/src/overlays.js +14 -7
- package/src/proxy-server.js +68 -6
- package/src/proxy-sync.js +5 -4
- package/src/proxy-topology.js +9 -4
- package/src/render-table.js +17 -37
- package/src/tool-launchers.js +81 -39
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,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-
|
|
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
|
-
- **📊
|
|
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
|
|
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.
|
|
185
|
+
**Version 0.3.4 cleans up the public proxy/docs surface and ships a small stability pass:**
|
|
186
186
|
|
|
187
|
-
- **
|
|
188
|
-
-
|
|
189
|
-
- **
|
|
190
|
-
- **
|
|
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
|
|
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
|
-
**
|
|
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
|
|
package/bin/fcm-proxy-daemon.js
CHANGED
|
@@ -88,7 +88,7 @@ async function main() {
|
|
|
88
88
|
const { sources } = await import('../sources.js')
|
|
89
89
|
|
|
90
90
|
// 📖 Load config and build initial topology — wrapped in try/catch to provide clear error on startup failures
|
|
91
|
-
let fcmConfig, proxySettings, mergedModels, accounts, proxyModels
|
|
91
|
+
let fcmConfig, proxySettings, mergedModels, accounts, proxyModels, anthropicRouting
|
|
92
92
|
try {
|
|
93
93
|
fcmConfig = loadConfig()
|
|
94
94
|
proxySettings = getProxySettings(fcmConfig)
|
|
@@ -113,6 +113,7 @@ async function main() {
|
|
|
113
113
|
const topology = buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources)
|
|
114
114
|
accounts = topology.accounts
|
|
115
115
|
proxyModels = topology.proxyModels
|
|
116
|
+
anthropicRouting = topology.anthropicRouting
|
|
116
117
|
} catch (err) {
|
|
117
118
|
logError(`Fatal: Failed to build initial topology: ${err.message}`)
|
|
118
119
|
process.exit(1)
|
|
@@ -130,6 +131,7 @@ async function main() {
|
|
|
130
131
|
port,
|
|
131
132
|
accounts,
|
|
132
133
|
proxyApiKey: token,
|
|
134
|
+
anthropicRouting,
|
|
133
135
|
})
|
|
134
136
|
|
|
135
137
|
try {
|
|
@@ -175,9 +177,10 @@ async function main() {
|
|
|
175
177
|
return
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
proxy.updateAccounts(newTopology.accounts)
|
|
180
|
+
proxy.updateAccounts(newTopology.accounts, newTopology.anthropicRouting)
|
|
179
181
|
accounts = newTopology.accounts
|
|
180
182
|
proxyModels = newTopology.proxyModels
|
|
183
|
+
anthropicRouting = newTopology.anthropicRouting
|
|
181
184
|
|
|
182
185
|
// 📖 Update status file
|
|
183
186
|
writeDaemonStatus({
|
|
@@ -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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
447
|
-
|
|
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
|
|
package/src/key-handler.js
CHANGED
|
@@ -959,13 +959,14 @@ export function createKeyHandler(ctx) {
|
|
|
959
959
|
if (state.settingsOpen) {
|
|
960
960
|
const proxySettings = getProxySettings(state.config)
|
|
961
961
|
const providerKeys = Object.keys(sources)
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
962
|
+
const updateRowIdx = providerKeys.length
|
|
963
|
+
const widthWarningRowIdx = updateRowIdx + 1
|
|
964
|
+
const proxyDaemonRowIdx = widthWarningRowIdx + 1
|
|
965
|
+
const changelogViewRowIdx = proxyDaemonRowIdx + 1
|
|
966
|
+
// 📖 Profile rows start after maintenance + width warning + proxy/daemon + changelog
|
|
967
|
+
const savedProfiles = listProfiles(state.config)
|
|
968
|
+
const profileStartIdx = updateRowIdx + 5
|
|
969
|
+
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : changelogViewRowIdx
|
|
969
970
|
|
|
970
971
|
// 📖 Edit/Add-key mode: capture typed characters for the API key
|
|
971
972
|
if (state.settingsEditMode || state.settingsAddKeyMode) {
|
|
@@ -1098,6 +1099,14 @@ export function createKeyHandler(ctx) {
|
|
|
1098
1099
|
return
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
1102
|
+
// 📖 Widths Warning toggle (Enter to toggle)
|
|
1103
|
+
if (state.settingsCursor === widthWarningRowIdx) {
|
|
1104
|
+
if (!state.config.settings) state.config.settings = {}
|
|
1105
|
+
state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
|
|
1106
|
+
saveConfig(state.config)
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1101
1110
|
// 📖 Proxy & Daemon row: Enter → open dedicated overlay
|
|
1102
1111
|
if (state.settingsCursor === proxyDaemonRowIdx) {
|
|
1103
1112
|
state.settingsOpen = false
|
|
@@ -1161,7 +1170,15 @@ export function createKeyHandler(ctx) {
|
|
|
1161
1170
|
}
|
|
1162
1171
|
|
|
1163
1172
|
if (key.name === 'space') {
|
|
1173
|
+
// 📖 Exclude certain rows from space toggle
|
|
1164
1174
|
if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyDaemonRowIdx || state.settingsCursor === changelogViewRowIdx) return
|
|
1175
|
+
// 📖 Widths Warning toggle (disable/enable)
|
|
1176
|
+
if (state.settingsCursor === widthWarningRowIdx) {
|
|
1177
|
+
if (!state.config.settings) state.config.settings = {}
|
|
1178
|
+
state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
|
|
1179
|
+
saveConfig(state.config)
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1165
1182
|
// 📖 Profile rows don't respond to Space
|
|
1166
1183
|
if (state.settingsCursor >= profileStartIdx) return
|
|
1167
1184
|
|
|
@@ -1204,7 +1221,7 @@ export function createKeyHandler(ctx) {
|
|
|
1204
1221
|
saveConfig(state.config)
|
|
1205
1222
|
// 📖 Re-clamp cursor after deletion (profile list just got shorter)
|
|
1206
1223
|
const newProfiles = listProfiles(state.config)
|
|
1207
|
-
const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 :
|
|
1224
|
+
const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : changelogViewRowIdx
|
|
1208
1225
|
if (state.settingsCursor > newMaxRowIdx) {
|
|
1209
1226
|
state.settingsCursor = Math.max(0, newMaxRowIdx)
|
|
1210
1227
|
}
|
|
@@ -1715,7 +1732,7 @@ export function createKeyHandler(ctx) {
|
|
|
1715
1732
|
// 📖 Shift+R is reserved for reset view settings
|
|
1716
1733
|
const sortKeys = {
|
|
1717
1734
|
'r': 'rank', 'o': 'origin', 'm': 'model',
|
|
1718
|
-
'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
|
|
1735
|
+
'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
|
|
1719
1736
|
}
|
|
1720
1737
|
|
|
1721
1738
|
if (sortKeys[key.name] && !key.ctrl && !key.shift) {
|
package/src/opencode.js
CHANGED
|
@@ -575,25 +575,25 @@ export async function ensureProxyRunning(fcmConfig, { forceRestart = false } = {
|
|
|
575
575
|
throw new Error('Proxy mode is disabled in Settings')
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
-
// 📖
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
578
|
+
// 📖 Always prefer the background daemon when it is available. Launcher code
|
|
579
|
+
// 📖 can update config and let the daemon hot-reload, which is closer to the
|
|
580
|
+
// 📖 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
|
-
}
|
|
596
|
-
}
|
|
595
|
+
}
|
|
596
|
+
} catch { /* daemon check failed — fall through to in-process */ }
|
|
597
597
|
|
|
598
598
|
if (forceRestart && activeProxy) {
|
|
599
599
|
await cleanupProxy()
|
|
@@ -613,7 +613,7 @@ export async function ensureProxyRunning(fcmConfig, { forceRestart = false } = {
|
|
|
613
613
|
}
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
const { accounts, proxyModels } = buildProxyTopologyFromConfig(fcmConfig)
|
|
616
|
+
const { accounts, proxyModels, anthropicRouting } = buildProxyTopologyFromConfig(fcmConfig)
|
|
617
617
|
if (accounts.length === 0) {
|
|
618
618
|
throw new Error('No API keys found for proxy-capable models')
|
|
619
619
|
}
|
|
@@ -622,7 +622,7 @@ export async function ensureProxyRunning(fcmConfig, { forceRestart = false } = {
|
|
|
622
622
|
const proxySettings = getProxySettings(fcmConfig)
|
|
623
623
|
const proxyToken = proxySettings.stableToken || `fcm_${randomUUID().replace(/-/g, '')}`
|
|
624
624
|
const preferredPort = Number.isInteger(proxySettings.preferredPort) ? proxySettings.preferredPort : 0
|
|
625
|
-
const proxy = new ProxyServer({ port: preferredPort, accounts, proxyApiKey: proxyToken })
|
|
625
|
+
const proxy = new ProxyServer({ port: preferredPort, accounts, proxyApiKey: proxyToken, anthropicRouting })
|
|
626
626
|
const { port } = await proxy.start()
|
|
627
627
|
activeProxy = proxy
|
|
628
628
|
setActiveProxy(activeProxy)
|
package/src/overlays.js
CHANGED
|
@@ -145,9 +145,10 @@ export function createOverlayRenderers(state, deps) {
|
|
|
145
145
|
// 📖 Key "T" in settings = test API key for selected provider.
|
|
146
146
|
function renderSettings() {
|
|
147
147
|
const providerKeys = Object.keys(sources)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
const updateRowIdx = providerKeys.length
|
|
149
|
+
const widthWarningRowIdx = updateRowIdx + 1
|
|
150
|
+
const proxyDaemonRowIdx = widthWarningRowIdx + 1
|
|
151
|
+
const changelogViewRowIdx = proxyDaemonRowIdx + 1
|
|
151
152
|
const proxySettings = getProxySettings(state.config)
|
|
152
153
|
const EL = '\x1b[K'
|
|
153
154
|
const lines = []
|
|
@@ -274,6 +275,13 @@ export function createOverlayRenderers(state, deps) {
|
|
|
274
275
|
const updateRow = `${updateBullet}${chalk.bold(updateActionLabel).padEnd(44)} ${updateStatus}`
|
|
275
276
|
cursorLineByRow[updateRowIdx] = lines.length
|
|
276
277
|
lines.push(updateCursor ? chalk.bgRgb(30, 30, 60)(updateRow) : updateRow)
|
|
278
|
+
// 📖 Widths Warning toggle row (disable widths warning)
|
|
279
|
+
const disableWidthsWarning = Boolean(state.config.settings?.disableWidthsWarning)
|
|
280
|
+
const widthWarningBullet = state.settingsCursor === widthWarningRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
281
|
+
const widthWarningStatus = disableWidthsWarning ? chalk.greenBright('DISABLED') : chalk.dim('enabled')
|
|
282
|
+
const widthWarningRow = `${widthWarningBullet}${chalk.bold('Disable Widths Warning').padEnd(44)} ${widthWarningStatus}`
|
|
283
|
+
cursorLineByRow[widthWarningRowIdx] = lines.length
|
|
284
|
+
lines.push(state.settingsCursor === widthWarningRowIdx ? chalk.bgRgb(30, 30, 60)(widthWarningRow) : widthWarningRow)
|
|
277
285
|
if (updateState === 'error' && state.settingsUpdateError) {
|
|
278
286
|
lines.push(chalk.red(` ${state.settingsUpdateError}`))
|
|
279
287
|
}
|
|
@@ -304,8 +312,8 @@ export function createOverlayRenderers(state, deps) {
|
|
|
304
312
|
|
|
305
313
|
// 📖 Profiles section — list saved profiles with active indicator + delete support
|
|
306
314
|
const savedProfiles = listProfiles(state.config)
|
|
307
|
-
|
|
308
|
-
|
|
315
|
+
const profileStartIdx = updateRowIdx + 5
|
|
316
|
+
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : changelogViewRowIdx
|
|
309
317
|
|
|
310
318
|
lines.push('')
|
|
311
319
|
lines.push(` ${chalk.bold('📋 Profiles')} ${chalk.dim(savedProfiles.length > 0 ? `(${savedProfiles.length} saved)` : '(none — press Shift+S in main view to save)')}`)
|
|
@@ -612,8 +620,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
612
620
|
lines.push(` ${chalk.cyan('Used')} Total prompt+completion tokens consumed in logs for this exact provider/model pair`)
|
|
613
621
|
lines.push(` ${chalk.dim('Loaded once at startup from request-log.jsonl. Displayed in K tokens, or M tokens above one million.')}`)
|
|
614
622
|
lines.push('')
|
|
615
|
-
|
|
616
|
-
lines.push(` ${chalk.dim('If a provider does not expose a trustworthy remaining %, the table shows a green dot instead of a fake number.')}`)
|
|
623
|
+
|
|
617
624
|
|
|
618
625
|
lines.push('')
|
|
619
626
|
lines.push(` ${chalk.bold('Main TUI')}`)
|
package/src/proxy-server.js
CHANGED
|
@@ -115,6 +115,15 @@ function normalizeRequestedModel(modelId) {
|
|
|
115
115
|
return trimmed.replace(/^fcm-proxy\//, '')
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
function normalizeAnthropicRouting(anthropicRouting = null) {
|
|
119
|
+
return {
|
|
120
|
+
model: normalizeRequestedModel(anthropicRouting?.model),
|
|
121
|
+
modelOpus: normalizeRequestedModel(anthropicRouting?.modelOpus),
|
|
122
|
+
modelSonnet: normalizeRequestedModel(anthropicRouting?.modelSonnet),
|
|
123
|
+
modelHaiku: normalizeRequestedModel(anthropicRouting?.modelHaiku),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
function classifyClaudeVirtualModel(modelId) {
|
|
119
128
|
const normalized = normalizeRequestedModel(modelId)
|
|
120
129
|
if (!normalized) return null
|
|
@@ -135,6 +144,22 @@ function classifyClaudeVirtualModel(modelId) {
|
|
|
135
144
|
return null
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
function resolveAnthropicMappedModel(modelId, anthropicRouting) {
|
|
148
|
+
const routing = normalizeAnthropicRouting(anthropicRouting)
|
|
149
|
+
const fallbackModel = routing.model
|
|
150
|
+
if (!fallbackModel && !routing.modelOpus && !routing.modelSonnet && !routing.modelHaiku) {
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const family = classifyClaudeVirtualModel(modelId)
|
|
155
|
+
if (family === 'opus') return routing.modelOpus || fallbackModel
|
|
156
|
+
if (family === 'sonnet') return routing.modelSonnet || fallbackModel
|
|
157
|
+
if (family === 'haiku') return routing.modelHaiku || fallbackModel
|
|
158
|
+
|
|
159
|
+
// 📖 free-claude-code falls back to MODEL for unknown Claude ids too.
|
|
160
|
+
return fallbackModel
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
function parseProxyAuthorizationHeader(authorization, expectedToken) {
|
|
139
164
|
if (!expectedToken) return { authorized: true, modelHint: null }
|
|
140
165
|
if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
|
|
@@ -160,6 +185,7 @@ export class ProxyServer {
|
|
|
160
185
|
* accounts?: Array<{ id: string, providerKey: string, apiKey: string, modelId: string, url: string }>,
|
|
161
186
|
* retries?: number,
|
|
162
187
|
* proxyApiKey?: string,
|
|
188
|
+
* anthropicRouting?: { model?: string|null, modelOpus?: string|null, modelSonnet?: string|null, modelHaiku?: string|null },
|
|
163
189
|
* accountManagerOpts?: object,
|
|
164
190
|
* tokenStatsOpts?: object,
|
|
165
191
|
* thinkingConfig?: { mode: string, budget_tokens?: number },
|
|
@@ -172,6 +198,7 @@ export class ProxyServer {
|
|
|
172
198
|
accounts = [],
|
|
173
199
|
retries = 3,
|
|
174
200
|
proxyApiKey = null,
|
|
201
|
+
anthropicRouting = null,
|
|
175
202
|
accountManagerOpts = {},
|
|
176
203
|
tokenStatsOpts = {},
|
|
177
204
|
thinkingConfig,
|
|
@@ -183,6 +210,7 @@ export class ProxyServer {
|
|
|
183
210
|
this._thinkingConfig = thinkingConfig
|
|
184
211
|
this._compressionOpts = compressionOpts
|
|
185
212
|
this._proxyApiKey = proxyApiKey
|
|
213
|
+
this._anthropicRouting = normalizeAnthropicRouting(anthropicRouting)
|
|
186
214
|
this._accounts = accounts
|
|
187
215
|
this._upstreamTimeoutMs = upstreamTimeoutMs
|
|
188
216
|
// 📖 Progressive backoff delays (ms) for retries — first attempt is immediate,
|
|
@@ -236,6 +264,7 @@ export class ProxyServer {
|
|
|
236
264
|
port: this._listeningPort,
|
|
237
265
|
accountCount: this._accounts.length,
|
|
238
266
|
healthByAccount: this._accountManager.getAllHealth(),
|
|
267
|
+
anthropicRouting: this._anthropicRouting,
|
|
239
268
|
}
|
|
240
269
|
}
|
|
241
270
|
|
|
@@ -253,13 +282,17 @@ export class ProxyServer {
|
|
|
253
282
|
return requestedModel
|
|
254
283
|
}
|
|
255
284
|
|
|
285
|
+
const mappedModel = resolveAnthropicMappedModel(requestedModel, this._anthropicRouting)
|
|
286
|
+
if (mappedModel && this._accountManager.hasAccountsForModel(mappedModel)) {
|
|
287
|
+
return mappedModel
|
|
288
|
+
}
|
|
289
|
+
|
|
256
290
|
// 📖 Claude Code still emits internal aliases / tier model ids for some
|
|
257
|
-
// 📖 background and helper paths.
|
|
258
|
-
// 📖
|
|
259
|
-
// 📖
|
|
260
|
-
// 📖 `claude-3-5-sonnet-20241022` behave the same as `sonnet`.
|
|
291
|
+
// 📖 background and helper paths. Keep the old auth-token hint as a final
|
|
292
|
+
// 📖 compatibility fallback for already-launched sessions, but the primary
|
|
293
|
+
// 📖 routing path is now the free-claude-code style proxy-side mapping above.
|
|
261
294
|
if (authModelHint && this._accountManager.hasAccountsForModel(authModelHint)) {
|
|
262
|
-
if (!requestedModel || classifyClaudeVirtualModel(requestedModel)) {
|
|
295
|
+
if (!requestedModel || classifyClaudeVirtualModel(requestedModel) || requestedModel.toLowerCase().startsWith('claude-')) {
|
|
263
296
|
return authModelHint
|
|
264
297
|
}
|
|
265
298
|
}
|
|
@@ -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', '
|
|
41
|
+
'aider', 'amp', 'qwen', 'codex', 'openhands',
|
|
40
42
|
]
|
|
41
43
|
|
|
42
44
|
const PROXY_SYNCABLE_CANONICAL = new Set(PROXY_SYNCABLE_TOOLS.map(tool => tool === 'opencode-desktop' ? 'opencode' : tool))
|
|
@@ -341,9 +343,8 @@ function syncEnvTool(proxyInfo, mergedModels, toolMode) {
|
|
|
341
343
|
// 📖 Claude Code: Anthropic-specific env vars
|
|
342
344
|
if (toolMode === 'claude-code') {
|
|
343
345
|
const proxyBase = proxyInfo.baseUrl.replace(/\/v1$/, '')
|
|
344
|
-
envLines.push(`export
|
|
346
|
+
envLines.push(`export ANTHROPIC_AUTH_TOKEN="${proxyInfo.token}"`)
|
|
345
347
|
envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
|
|
346
|
-
envLines.push(`export ANTHROPIC_MODEL="${primarySlug}"`)
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
ensureDirFor(envFilePath)
|
package/src/proxy-topology.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* The proxy server uses these accounts for multi-key rotation and load balancing.
|
|
10
10
|
*
|
|
11
11
|
* @functions
|
|
12
|
-
* → buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources) — build accounts + proxyModels
|
|
12
|
+
* → buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources) — build accounts + proxyModels + Anthropic family routing
|
|
13
13
|
* → buildMergedModelsForDaemon() — standalone helper to build merged models without TUI
|
|
14
14
|
*
|
|
15
15
|
* @exports buildProxyTopologyFromConfig, buildMergedModelsForDaemon
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* @see bin/fcm-proxy-daemon.js — standalone daemon that uses this directly
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { resolveApiKeys } from './config.js'
|
|
20
|
+
import { resolveApiKeys, getProxySettings } from './config.js'
|
|
21
21
|
import { resolveCloudflareUrl } from './ping.js'
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -29,7 +29,7 @@ import { resolveCloudflareUrl } from './ping.js'
|
|
|
29
29
|
* @param {object} fcmConfig — live config from loadConfig()
|
|
30
30
|
* @param {Array} mergedModels — output of buildMergedModels(MODELS)
|
|
31
31
|
* @param {object} sourcesMap — the sources object keyed by providerKey
|
|
32
|
-
* @returns {{ accounts: Array, proxyModels: Record<string, { name: string }
|
|
32
|
+
* @returns {{ accounts: Array, proxyModels: Record<string, { name: string }>, anthropicRouting: { model: string|null, modelOpus: string|null, modelSonnet: string|null, modelHaiku: string|null } }}
|
|
33
33
|
*/
|
|
34
34
|
export function buildProxyTopologyFromConfig(fcmConfig, mergedModels, sourcesMap) {
|
|
35
35
|
const accounts = []
|
|
@@ -64,7 +64,12 @@ export function buildProxyTopologyFromConfig(fcmConfig, mergedModels, sourcesMap
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
accounts,
|
|
69
|
+
proxyModels,
|
|
70
|
+
// 📖 Mirror Claude proxy: proxy-side Claude family routing is config-driven.
|
|
71
|
+
anthropicRouting: getProxySettings(fcmConfig).anthropicRouting,
|
|
72
|
+
}
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
/**
|
package/src/render-table.js
CHANGED
|
@@ -93,7 +93,7 @@ export function setActiveProxy(proxyInstance) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
|
|
96
|
-
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, startupLatestVersion = null, versionAlertsEnabled = true) {
|
|
96
|
+
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, startupLatestVersion = null, versionAlertsEnabled = true, disableWidthsWarning = false) {
|
|
97
97
|
// 📖 Filter out hidden models for display
|
|
98
98
|
const visibleResults = results.filter(r => !r.hidden)
|
|
99
99
|
|
|
@@ -192,12 +192,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
192
192
|
const W_STAB = 11
|
|
193
193
|
const W_UPTIME = 6
|
|
194
194
|
const W_TOKENS = 7
|
|
195
|
-
const W_USAGE = 7
|
|
195
|
+
// const W_USAGE = 7 // Usage column removed
|
|
196
196
|
const MIN_TABLE_WIDTH = 166
|
|
197
197
|
const warningDurationMs = 4_000
|
|
198
198
|
const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
|
|
199
199
|
const remainingMs = Math.max(0, warningDurationMs - elapsed)
|
|
200
|
-
const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && widthWarningShowCount < 2 && remainingMs > 0
|
|
200
|
+
const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !disableWidthsWarning && !widthWarningDismissed && widthWarningShowCount < 2 && remainingMs > 0
|
|
201
201
|
|
|
202
202
|
if (showWidthWarning) {
|
|
203
203
|
const lines = []
|
|
@@ -216,6 +216,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
216
216
|
lines.push(' '.repeat(padLeft3) + chalk.red(warning3))
|
|
217
217
|
lines.push('')
|
|
218
218
|
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + chalk.yellow(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
|
|
219
|
+
const barTotal = Math.max(0, Math.min(terminalCols - 4, 30))
|
|
220
|
+
const barFill = Math.round((elapsed / warningDurationMs) * barTotal)
|
|
221
|
+
const barStr = chalk.green('█'.repeat(barFill)) + chalk.dim('░'.repeat(barTotal - barFill))
|
|
222
|
+
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - barTotal) / 2))) + barStr)
|
|
219
223
|
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 20) / 2))) + chalk.dim('press esc to dismiss'))
|
|
220
224
|
while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
|
|
221
225
|
const EL = '\x1b[K'
|
|
@@ -297,17 +301,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
297
301
|
return chalk.yellow.bold('U') + chalk.dim('p%' + padding)
|
|
298
302
|
})()
|
|
299
303
|
const tokensH_c = chalk.dim(tokensH.padEnd(W_TOKENS))
|
|
300
|
-
// 📖 Usage
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const padding = ' '.repeat(Math.max(0, W_USAGE - plain.length))
|
|
304
|
-
return chalk.dim('Usa') + chalk.yellow.bold('G') + chalk.dim('e' + padding)
|
|
305
|
-
})()
|
|
306
|
-
|
|
307
|
-
// 📖 Header with proper spacing (column order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used, Usage)
|
|
308
|
-
lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + stabH_c + ' ' + uptimeH_c + ' ' + tokensH_c + ' ' + usageH_c)
|
|
304
|
+
// 📖 Usage column removed from UI – no header or separator for it.
|
|
305
|
+
// Header without Usage column (column order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used)
|
|
306
|
+
lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + stabH_c + ' ' + uptimeH_c + ' ' + tokensH_c)
|
|
309
307
|
|
|
310
|
-
//
|
|
308
|
+
// Separator line without Usage column
|
|
311
309
|
lines.push(
|
|
312
310
|
' ' +
|
|
313
311
|
chalk.dim('─'.repeat(W_RANK)) + ' ' +
|
|
@@ -322,8 +320,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
322
320
|
chalk.dim('─'.repeat(W_VERDICT)) + ' ' +
|
|
323
321
|
chalk.dim('─'.repeat(W_STAB)) + ' ' +
|
|
324
322
|
chalk.dim('─'.repeat(W_UPTIME)) + ' ' +
|
|
325
|
-
chalk.dim('─'.repeat(W_TOKENS))
|
|
326
|
-
chalk.dim('─'.repeat(W_USAGE))
|
|
323
|
+
chalk.dim('─'.repeat(W_TOKENS))
|
|
327
324
|
)
|
|
328
325
|
|
|
329
326
|
if (sorted.length === 0) {
|
|
@@ -563,26 +560,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
563
560
|
const sourceCursorText = providerName.padEnd(W_SOURCE)
|
|
564
561
|
const sourceCell = isCursor ? chalk.rgb(...providerRgb).bold(sourceCursorText) : source
|
|
565
562
|
|
|
566
|
-
// 📖 Usage column
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
if (r.usagePercent !== undefined && r.usagePercent !== null) {
|
|
570
|
-
const usageStr = Math.round(r.usagePercent) + '%'
|
|
571
|
-
if (r.usagePercent >= 80) {
|
|
572
|
-
usageCell = chalk.greenBright(usageStr.padEnd(W_USAGE))
|
|
573
|
-
} else if (r.usagePercent >= 50) {
|
|
574
|
-
usageCell = chalk.yellow(usageStr.padEnd(W_USAGE))
|
|
575
|
-
} else if (r.usagePercent >= 20) {
|
|
576
|
-
usageCell = chalk.rgb(255, 165, 0)(usageStr.padEnd(W_USAGE)) // orange
|
|
577
|
-
} else {
|
|
578
|
-
usageCell = chalk.red(usageStr.padEnd(W_USAGE))
|
|
579
|
-
}
|
|
580
|
-
} else {
|
|
581
|
-
const usagePlaceholder = usagePlaceholderForProvider(r.providerKey)
|
|
582
|
-
usageCell = usagePlaceholder === '🟢'
|
|
583
|
-
? chalk.greenBright(usagePlaceholder.padEnd(W_USAGE))
|
|
584
|
-
: chalk.dim(usagePlaceholder.padEnd(W_USAGE))
|
|
585
|
-
}
|
|
563
|
+
// 📖 Usage column removed from UI – no usage data displayed.
|
|
564
|
+
// (We keep the logic but do not render it.)
|
|
565
|
+
const usageCell = ''
|
|
586
566
|
|
|
587
567
|
// 📖 Used column — total historical prompt+completion tokens consumed for this
|
|
588
568
|
// 📖 exact provider/model pair, loaded once from request-log.jsonl at startup.
|
|
@@ -591,8 +571,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
591
571
|
? chalk.rgb(120, 210, 255)(formatTokenTotalCompact(tokenTotal).padEnd(W_TOKENS))
|
|
592
572
|
: chalk.dim('0'.padEnd(W_TOKENS))
|
|
593
573
|
|
|
594
|
-
// 📖 Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used
|
|
595
|
-
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell
|
|
574
|
+
// 📖 Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used)
|
|
575
|
+
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell
|
|
596
576
|
|
|
597
577
|
if (isCursor) {
|
|
598
578
|
lines.push(chalk.bgRgb(155, 55, 135)(row))
|
package/src/tool-launchers.js
CHANGED
|
@@ -19,22 +19,24 @@
|
|
|
19
19
|
* 📖 Crush: writes crush.json with provider config + models.large/small defaults
|
|
20
20
|
* 📖 Pi: uses --provider/--model CLI flags for guaranteed auto-selection
|
|
21
21
|
* 📖 Aider: writes ~/.aider.conf.yml + passes --model flag
|
|
22
|
-
* 📖 Claude Code:
|
|
22
|
+
* 📖 Claude Code: mirrors 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
|
-
* → `
|
|
29
|
-
* → `
|
|
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,
|
|
37
|
-
* @exports
|
|
38
|
+
* @exports resolveLauncherModelId, waitForClaudeProxyRouting, buildClaudeProxyArgs, buildCodexProxyArgs
|
|
39
|
+
* @exports inspectGeminiCliSupport, startExternalTool
|
|
38
40
|
*
|
|
39
41
|
* @see src/tool-metadata.js
|
|
40
42
|
* @see src/provider-metadata.js
|
|
@@ -48,7 +50,7 @@ import { delimiter, dirname, join } from 'path'
|
|
|
48
50
|
import { spawn, spawnSync } from 'child_process'
|
|
49
51
|
import { sources } from '../sources.js'
|
|
50
52
|
import { PROVIDER_COLOR } from './render-table.js'
|
|
51
|
-
import { getApiKey, getProxySettings } from './config.js'
|
|
53
|
+
import { getApiKey, getProxySettings, saveConfig, setClaudeProxyModelRouting } from './config.js'
|
|
52
54
|
import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
|
|
53
55
|
import { getToolMeta } from './tool-metadata.js'
|
|
54
56
|
import { ensureProxyRunning, resolveProxyModelId } from './opencode.js'
|
|
@@ -83,6 +85,9 @@ const GEMINI_ENV_KEYS = [
|
|
|
83
85
|
const PROXY_SANITIZED_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS, ...ANTHROPIC_ENV_KEYS, ...GEMINI_ENV_KEYS]
|
|
84
86
|
const GEMINI_PROXY_MIN_VERSION = '0.34.0'
|
|
85
87
|
const EXPERIMENTAL_PROXY_TOOLS_NOTE = 'FCM Proxy V2 support for external tools is still in beta, so some launch and authentication flows can remain flaky while the integration stabilizes.'
|
|
88
|
+
const CLAUDE_PROXY_RELOAD_TIMEOUT_MS = 4000
|
|
89
|
+
const CLAUDE_PROXY_RELOAD_INTERVAL_MS = 200
|
|
90
|
+
const CLAUDE_PROXY_CLIENT_MODEL = 'sonnet'
|
|
86
91
|
|
|
87
92
|
function ensureDir(filePath) {
|
|
88
93
|
const dir = dirname(filePath)
|
|
@@ -154,26 +159,15 @@ export function resolveLauncherModelId(model, useProxy = false) {
|
|
|
154
159
|
return model?.modelId ?? ''
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolvedModelId
|
|
167
|
-
env.ANTHROPIC_SMALL_FAST_MODEL = resolvedModelId
|
|
168
|
-
env.CLAUDE_CODE_SUBAGENT_MODEL = resolvedModelId
|
|
169
|
-
return env
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function buildClaudeProxyAuthToken(proxyToken, modelId) {
|
|
173
|
-
const resolvedProxyToken = typeof proxyToken === 'string' ? proxyToken.trim() : ''
|
|
174
|
-
const resolvedModelId = typeof modelId === 'string' ? modelId.trim() : ''
|
|
175
|
-
if (!resolvedProxyToken) return ''
|
|
176
|
-
return resolvedModelId ? `${resolvedProxyToken}:${resolvedModelId}` : resolvedProxyToken
|
|
162
|
+
/**
|
|
163
|
+
* 📖 Force Claude Code to start on a real Claude alias, never on an FCM slug.
|
|
164
|
+
* 📖 Older FCM launches poisoned Claude's local model state with `gpt-oss-*`,
|
|
165
|
+
* 📖 and Claude rejects those client-side before any proxy request is made.
|
|
166
|
+
*
|
|
167
|
+
* @returns {string[]}
|
|
168
|
+
*/
|
|
169
|
+
export function buildClaudeProxyArgs() {
|
|
170
|
+
return ['--model', CLAUDE_PROXY_CLIENT_MODEL]
|
|
177
171
|
}
|
|
178
172
|
|
|
179
173
|
export function buildToolEnv(mode, model, config, options = {}) {
|
|
@@ -186,7 +180,7 @@ export function buildToolEnv(mode, model, config, options = {}) {
|
|
|
186
180
|
const providerKey = model.providerKey
|
|
187
181
|
const providerUrl = sources[providerKey]?.url || ''
|
|
188
182
|
const baseUrl = getProviderBaseUrl(providerKey)
|
|
189
|
-
const apiKey = getApiKey(config, providerKey)
|
|
183
|
+
const apiKey = sanitize ? (config?.apiKeys?.[providerKey] ?? null) : getApiKey(config, providerKey)
|
|
190
184
|
const env = cloneInheritedEnv(inheritedEnv, sanitize ? PROXY_SANITIZED_ENV_KEYS : [])
|
|
191
185
|
const providerEnvName = ENV_VAR_NAMES[providerKey]
|
|
192
186
|
if (includeProviderEnv && providerEnvName && apiKey) env[providerEnvName] = apiKey
|
|
@@ -206,7 +200,6 @@ export function buildToolEnv(mode, model, config, options = {}) {
|
|
|
206
200
|
if (mode === 'claude-code' && apiKey && baseUrl) {
|
|
207
201
|
env.ANTHROPIC_AUTH_TOKEN = apiKey
|
|
208
202
|
env.ANTHROPIC_BASE_URL = baseUrl
|
|
209
|
-
env.ANTHROPIC_MODEL = model.modelId
|
|
210
203
|
}
|
|
211
204
|
|
|
212
205
|
if (mode === 'gemini' && apiKey && baseUrl) {
|
|
@@ -218,6 +211,31 @@ export function buildToolEnv(mode, model, config, options = {}) {
|
|
|
218
211
|
return { env, apiKey, baseUrl, providerUrl }
|
|
219
212
|
}
|
|
220
213
|
|
|
214
|
+
export async function waitForClaudeProxyRouting(port, token, expectedModelId) {
|
|
215
|
+
const expected = typeof expectedModelId === 'string' ? expectedModelId.trim().replace(/^fcm-proxy\//, '') : ''
|
|
216
|
+
if (!expected || !port || !token) return false
|
|
217
|
+
|
|
218
|
+
const deadline = Date.now() + CLAUDE_PROXY_RELOAD_TIMEOUT_MS
|
|
219
|
+
while (Date.now() < deadline) {
|
|
220
|
+
try {
|
|
221
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/stats`, {
|
|
222
|
+
headers: { authorization: `Bearer ${token}` },
|
|
223
|
+
})
|
|
224
|
+
if (res.ok) {
|
|
225
|
+
const payload = await res.json()
|
|
226
|
+
const active = payload?.anthropicRouting?.model
|
|
227
|
+
if (typeof active === 'string' && active.replace(/^fcm-proxy\//, '') === expected) {
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch { /* daemon may still be reloading — keep polling */ }
|
|
232
|
+
|
|
233
|
+
await new Promise(resolve => setTimeout(resolve, CLAUDE_PROXY_RELOAD_INTERVAL_MS))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
|
|
221
239
|
export function buildCodexProxyArgs(baseUrl) {
|
|
222
240
|
return [
|
|
223
241
|
'-c', 'model_provider="fcm_proxy"',
|
|
@@ -608,9 +626,8 @@ export async function startExternalTool(mode, model, config) {
|
|
|
608
626
|
return spawnCommand('goose', [], env)
|
|
609
627
|
}
|
|
610
628
|
|
|
611
|
-
// 📖
|
|
612
|
-
|
|
613
|
-
if (mode === 'claude-code' || mode === 'codex' || mode === 'gemini') {
|
|
629
|
+
// 📖 Codex and Gemini require FCM Proxy V2 to talk to the free-provider mesh.
|
|
630
|
+
if (mode === 'codex' || mode === 'gemini') {
|
|
614
631
|
if (!proxySettings.enabled) {
|
|
615
632
|
console.log()
|
|
616
633
|
console.log(chalk.red(` ✖ ${meta.label} requires FCM Proxy V2 to work with free providers.`))
|
|
@@ -629,21 +646,46 @@ export async function startExternalTool(mode, model, config) {
|
|
|
629
646
|
}
|
|
630
647
|
|
|
631
648
|
if (mode === 'claude-code') {
|
|
632
|
-
// 📖 Claude
|
|
633
|
-
// 📖
|
|
634
|
-
|
|
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
|
|
672
|
+
const claudeProxyToken = `${started.proxyToken}:${launchModelId}`
|
|
642
673
|
proxyEnv.ANTHROPIC_BASE_URL = proxyBase
|
|
643
|
-
proxyEnv.ANTHROPIC_AUTH_TOKEN =
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
674
|
+
proxyEnv.ANTHROPIC_AUTH_TOKEN = claudeProxyToken
|
|
675
|
+
|
|
676
|
+
const routingReady = await waitForClaudeProxyRouting(started.port, started.proxyToken, launchModelId)
|
|
677
|
+
if (!routingReady) {
|
|
678
|
+
console.log(chalk.yellow(` ⚠ Claude proxy routing reload is taking longer than expected; launching anyway.`))
|
|
679
|
+
console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (routingChanged && proxySettings.enabled !== true) {
|
|
683
|
+
console.log(chalk.dim(' 📖 Proxy mode was auto-enabled for Claude Code because this integration is proxy-only.'))
|
|
684
|
+
}
|
|
685
|
+
console.log(chalk.dim(` 📖 Claude Code routed through FCM proxy on :${started.port} with proxy-side Claude model mapping`))
|
|
686
|
+
console.log(chalk.dim(` 📖 Claude itself is forced onto the safe alias: ${CLAUDE_PROXY_CLIENT_MODEL}`))
|
|
687
|
+
console.log(chalk.dim(` 📖 All Claude families now resolve to: ${model.label} (${launchModelId})`))
|
|
688
|
+
return spawnCommand('claude', buildClaudeProxyArgs(), proxyEnv)
|
|
647
689
|
}
|
|
648
690
|
|
|
649
691
|
if (mode === 'codex') {
|