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