free-coding-models 0.3.9 → 0.3.12
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 +40 -0
- package/README.md +112 -1134
- package/bin/free-coding-models.js +34 -188
- package/package.json +2 -3
- package/src/cli-help.js +0 -18
- package/src/config.js +17 -351
- package/src/endpoint-installer.js +26 -64
- package/src/favorites.js +0 -14
- package/src/key-handler.js +74 -641
- package/src/legacy-proxy-cleanup.js +432 -0
- package/src/openclaw.js +69 -108
- package/src/opencode-config.js +48 -0
- package/src/opencode.js +6 -248
- package/src/overlays.js +26 -550
- package/src/product-flags.js +14 -0
- package/src/render-helpers.js +2 -34
- package/src/render-table.js +14 -33
- package/src/testfcm.js +90 -43
- package/src/token-usage-reader.js +9 -38
- package/src/tool-launchers.js +235 -409
- package/src/tool-metadata.js +0 -7
- package/src/utils.js +8 -77
- package/bin/fcm-proxy-daemon.js +0 -242
- package/src/account-manager.js +0 -634
- package/src/anthropic-translator.js +0 -440
- package/src/daemon-manager.js +0 -527
- package/src/error-classifier.js +0 -154
- package/src/log-reader.js +0 -195
- package/src/opencode-sync.js +0 -200
- package/src/proxy-server.js +0 -1477
- package/src/proxy-sync.js +0 -565
- package/src/proxy-topology.js +0 -85
- package/src/request-transformer.js +0 -180
- package/src/responses-translator.js +0 -423
- package/src/token-stats.js +0 -320
package/src/proxy-sync.js
DELETED
|
@@ -1,565 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file src/proxy-sync.js
|
|
3
|
-
* @description Generalized proxy sync & cleanup for all supported tools.
|
|
4
|
-
*
|
|
5
|
-
* @details
|
|
6
|
-
* 📖 When FCM Proxy V2 is enabled, there is ONE endpoint (http://127.0.0.1:{port}/v1)
|
|
7
|
-
* with ONE token that serves ALL models. This module writes that single endpoint into
|
|
8
|
-
* whichever tool the user has selected, and cleans up old per-provider `fcm-*` vestiges.
|
|
9
|
-
*
|
|
10
|
-
* 📖 Each tool has its own config format (JSON, YAML, env file). The sync functions
|
|
11
|
-
* know how to write the `fcm-proxy` entry in each format. The cleanup functions
|
|
12
|
-
* remove ALL `fcm-*` entries (both old direct installs and previous proxy entries).
|
|
13
|
-
*
|
|
14
|
-
* @functions
|
|
15
|
-
* → syncProxyToTool(toolMode, proxyInfo, mergedModels) — write proxy endpoint to tool config
|
|
16
|
-
* → cleanupToolConfig(toolMode) — remove all FCM entries from tool config
|
|
17
|
-
* → resolveProxySyncToolMode(toolMode) — normalize a live tool mode to a proxy-syncable target
|
|
18
|
-
* → getProxySyncableTools() — list of tools that support proxy sync
|
|
19
|
-
*
|
|
20
|
-
* @exports syncProxyToTool, cleanupToolConfig, resolveProxySyncToolMode, getProxySyncableTools, PROXY_SYNCABLE_TOOLS
|
|
21
|
-
*
|
|
22
|
-
* @see src/endpoint-installer.js — per-provider direct install (Y key flow)
|
|
23
|
-
* @see src/opencode-sync.js — OpenCode-specific sync (used internally by this module)
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
27
|
-
import { homedir } from 'node:os'
|
|
28
|
-
import { join } from 'node:path'
|
|
29
|
-
import { syncToOpenCode, cleanupOpenCodeProxyConfig } from './opencode-sync.js'
|
|
30
|
-
import { getToolMeta } from './tool-metadata.js'
|
|
31
|
-
|
|
32
|
-
// 📖 Provider ID used for all proxy entries — replaces per-provider fcm-{providerKey} IDs
|
|
33
|
-
const PROXY_PROVIDER_ID = 'fcm-proxy'
|
|
34
|
-
|
|
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.
|
|
37
|
-
// 📖 Claude proxy integration is
|
|
38
|
-
// 📖 runtime-only now, with fake Claude ids handled by the proxy itself.
|
|
39
|
-
export const PROXY_SYNCABLE_TOOLS = [
|
|
40
|
-
'opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose', 'pi',
|
|
41
|
-
'aider', 'amp', 'qwen', 'codex', 'openhands',
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
const PROXY_SYNCABLE_CANONICAL = new Set(PROXY_SYNCABLE_TOOLS.map(tool => tool === 'opencode-desktop' ? 'opencode' : tool))
|
|
45
|
-
|
|
46
|
-
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
function getDefaultPaths() {
|
|
49
|
-
const home = homedir()
|
|
50
|
-
return {
|
|
51
|
-
opencodeConfigPath: join(home, '.config', 'opencode', 'opencode.json'),
|
|
52
|
-
openclawConfigPath: join(home, '.openclaw', 'openclaw.json'),
|
|
53
|
-
crushConfigPath: join(home, '.config', 'crush', 'crush.json'),
|
|
54
|
-
gooseProvidersDir: join(home, '.config', 'goose', 'custom_providers'),
|
|
55
|
-
gooseSecretsPath: join(home, '.config', 'goose', 'secrets.yaml'),
|
|
56
|
-
piModelsPath: join(home, '.pi', 'agent', 'models.json'),
|
|
57
|
-
piSettingsPath: join(home, '.pi', 'agent', 'settings.json'),
|
|
58
|
-
aiderConfigPath: join(home, '.aider.conf.yml'),
|
|
59
|
-
ampConfigPath: join(home, '.config', 'amp', 'settings.json'),
|
|
60
|
-
qwenConfigPath: join(home, '.qwen', 'settings.json'),
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function ensureDirFor(filePath) {
|
|
65
|
-
const dir = join(filePath, '..')
|
|
66
|
-
mkdirSync(dir, { recursive: true })
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function readJson(filePath, fallback = {}) {
|
|
70
|
-
try {
|
|
71
|
-
if (existsSync(filePath)) return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
72
|
-
} catch { /* corrupted — start fresh */ }
|
|
73
|
-
return { ...fallback }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function writeJson(filePath, data) {
|
|
77
|
-
ensureDirFor(filePath)
|
|
78
|
-
const backupPath = filePath + '.bak'
|
|
79
|
-
if (existsSync(filePath)) {
|
|
80
|
-
try { copyFileSync(filePath, backupPath) } catch { /* best effort */ }
|
|
81
|
-
}
|
|
82
|
-
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n')
|
|
83
|
-
return backupPath
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function readSimpleYamlMap(filePath) {
|
|
87
|
-
if (!existsSync(filePath)) return {}
|
|
88
|
-
const out = {}
|
|
89
|
-
const lines = readFileSync(filePath, 'utf8').split(/\r?\n/)
|
|
90
|
-
for (const line of lines) {
|
|
91
|
-
if (!line.trim() || line.trim().startsWith('#')) continue
|
|
92
|
-
const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
|
|
93
|
-
if (!match) continue
|
|
94
|
-
let value = match[2].trim()
|
|
95
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
|
96
|
-
value = value.slice(1, -1)
|
|
97
|
-
}
|
|
98
|
-
out[match[1]] = value
|
|
99
|
-
}
|
|
100
|
-
return out
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function writeSimpleYamlMap(filePath, entries) {
|
|
104
|
-
ensureDirFor(filePath)
|
|
105
|
-
const lines = Object.keys(entries).sort().map(key => `${key}: ${JSON.stringify(String(entries[key] ?? ''))}`)
|
|
106
|
-
writeFileSync(filePath, lines.join('\n') + '\n')
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// 📖 Build proxy model list from mergedModels array
|
|
110
|
-
// 📖 Each model has { slug, label, ctx } — slug is the proxy model ID
|
|
111
|
-
function buildProxyModels(mergedModels) {
|
|
112
|
-
return mergedModels.map(m => ({ slug: m.slug, label: m.label, ctx: m.ctx || '128k' }))
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function parseContextWindow(ctx) {
|
|
116
|
-
if (typeof ctx !== 'string' || !ctx.trim()) return 128000
|
|
117
|
-
const trimmed = ctx.trim().toLowerCase()
|
|
118
|
-
const multiplier = trimmed.endsWith('m') ? 1_000_000 : trimmed.endsWith('k') ? 1_000 : 1
|
|
119
|
-
const numeric = Number.parseFloat(trimmed.replace(/[mk]$/i, ''))
|
|
120
|
-
if (!Number.isFinite(numeric) || numeric <= 0) return 128000
|
|
121
|
-
return Math.round(numeric * multiplier)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function getDefaultMaxTokens(contextWindow) {
|
|
125
|
-
return Math.max(4096, Math.min(contextWindow, 32768))
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function resolveProxySyncToolMode(toolMode) {
|
|
129
|
-
if (typeof toolMode !== 'string' || toolMode.length === 0) return null
|
|
130
|
-
const canonical = toolMode === 'opencode-desktop' ? 'opencode' : toolMode
|
|
131
|
-
return PROXY_SYNCABLE_CANONICAL.has(canonical) ? canonical : null
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ─── Per-tool sync functions ─────────────────────────────────────────────────
|
|
135
|
-
// 📖 Each writes a single `fcm-proxy` provider entry with ALL models
|
|
136
|
-
|
|
137
|
-
function syncOpenCode(proxyInfo, mergedModels) {
|
|
138
|
-
// 📖 Delegate to the existing OpenCode sync module
|
|
139
|
-
return syncToOpenCode(null, null, mergedModels, proxyInfo)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function syncOpenClaw(proxyInfo, mergedModels, paths) {
|
|
143
|
-
const filePath = paths.openclawConfigPath
|
|
144
|
-
const config = readJson(filePath, {})
|
|
145
|
-
const models = buildProxyModels(mergedModels)
|
|
146
|
-
|
|
147
|
-
if (!config.models || typeof config.models !== 'object') config.models = {}
|
|
148
|
-
if (config.models.mode !== 'replace') config.models.mode = 'merge'
|
|
149
|
-
if (!config.models.providers || typeof config.models.providers !== 'object') config.models.providers = {}
|
|
150
|
-
if (!config.agents || typeof config.agents !== 'object') config.agents = {}
|
|
151
|
-
if (!config.agents.defaults || typeof config.agents.defaults !== 'object') config.agents.defaults = {}
|
|
152
|
-
if (!config.agents.defaults.models || typeof config.agents.defaults.models !== 'object') config.agents.defaults.models = {}
|
|
153
|
-
|
|
154
|
-
// 📖 Remove old fcm-* providers (direct installs vestiges)
|
|
155
|
-
for (const key of Object.keys(config.models.providers)) {
|
|
156
|
-
if (key.startsWith('fcm-')) delete config.models.providers[key]
|
|
157
|
-
}
|
|
158
|
-
for (const modelRef of Object.keys(config.agents.defaults.models)) {
|
|
159
|
-
if (modelRef.startsWith('fcm-')) delete config.agents.defaults.models[modelRef]
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// 📖 Write single fcm-proxy provider with all models
|
|
163
|
-
config.models.providers[PROXY_PROVIDER_ID] = {
|
|
164
|
-
baseUrl: proxyInfo.baseUrl,
|
|
165
|
-
apiKey: proxyInfo.token,
|
|
166
|
-
api: 'openai-completions',
|
|
167
|
-
models: models.map(m => {
|
|
168
|
-
const contextWindow = parseContextWindow(m.ctx)
|
|
169
|
-
return {
|
|
170
|
-
id: m.slug, name: m.label, api: 'openai-completions', reasoning: false,
|
|
171
|
-
input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
172
|
-
contextWindow, maxTokens: getDefaultMaxTokens(contextWindow),
|
|
173
|
-
}
|
|
174
|
-
}),
|
|
175
|
-
}
|
|
176
|
-
for (const m of models) {
|
|
177
|
-
config.agents.defaults.models[`${PROXY_PROVIDER_ID}/${m.slug}`] = {}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
writeJson(filePath, config)
|
|
181
|
-
return { path: filePath, modelCount: models.length }
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function syncCrush(proxyInfo, mergedModels, paths) {
|
|
185
|
-
const filePath = paths.crushConfigPath
|
|
186
|
-
const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
|
|
187
|
-
if (!config.providers || typeof config.providers !== 'object') config.providers = {}
|
|
188
|
-
const models = buildProxyModels(mergedModels)
|
|
189
|
-
|
|
190
|
-
// 📖 Remove old fcm-* providers
|
|
191
|
-
for (const key of Object.keys(config.providers)) {
|
|
192
|
-
if (key.startsWith('fcm-')) delete config.providers[key]
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
config.providers[PROXY_PROVIDER_ID] = {
|
|
196
|
-
name: 'FCM Proxy V2',
|
|
197
|
-
type: 'openai-compat',
|
|
198
|
-
base_url: proxyInfo.baseUrl,
|
|
199
|
-
api_key: proxyInfo.token,
|
|
200
|
-
models: models.map(m => {
|
|
201
|
-
const contextWindow = parseContextWindow(m.ctx)
|
|
202
|
-
return { id: m.slug, name: m.label, context_window: contextWindow, default_max_tokens: getDefaultMaxTokens(contextWindow) }
|
|
203
|
-
}),
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
writeJson(filePath, config)
|
|
207
|
-
return { path: filePath, modelCount: models.length }
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function syncGoose(proxyInfo, mergedModels, paths) {
|
|
211
|
-
const models = buildProxyModels(mergedModels)
|
|
212
|
-
const providerFilePath = join(paths.gooseProvidersDir, `${PROXY_PROVIDER_ID}.json`)
|
|
213
|
-
|
|
214
|
-
// 📖 Remove old fcm-* provider files
|
|
215
|
-
try {
|
|
216
|
-
if (existsSync(paths.gooseProvidersDir)) {
|
|
217
|
-
for (const f of readdirSync(paths.gooseProvidersDir)) {
|
|
218
|
-
if (f.startsWith('fcm-') && f !== `${PROXY_PROVIDER_ID}.json`) {
|
|
219
|
-
try { unlinkSync(join(paths.gooseProvidersDir, f)) } catch { /* best effort */ }
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
} catch { /* best effort */ }
|
|
224
|
-
|
|
225
|
-
const providerConfig = {
|
|
226
|
-
name: PROXY_PROVIDER_ID,
|
|
227
|
-
engine: 'openai',
|
|
228
|
-
display_name: 'FCM Proxy V2',
|
|
229
|
-
description: 'Managed by free-coding-models — single endpoint for all models',
|
|
230
|
-
api_key_env: 'FCM_PROXY_API_KEY',
|
|
231
|
-
base_url: proxyInfo.baseUrl,
|
|
232
|
-
models: models.map(m => ({ name: m.slug, context_limit: parseContextWindow(m.ctx) })),
|
|
233
|
-
supports_streaming: true,
|
|
234
|
-
requires_auth: true,
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
writeJson(providerFilePath, providerConfig)
|
|
238
|
-
|
|
239
|
-
// 📖 Write secret + clean old fcm-* secrets
|
|
240
|
-
const secrets = readSimpleYamlMap(paths.gooseSecretsPath)
|
|
241
|
-
for (const key of Object.keys(secrets)) {
|
|
242
|
-
if (key.startsWith('FCM_') && key.endsWith('_API_KEY') && key !== 'FCM_PROXY_API_KEY') {
|
|
243
|
-
delete secrets[key]
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
secrets.FCM_PROXY_API_KEY = proxyInfo.token
|
|
247
|
-
writeSimpleYamlMap(paths.gooseSecretsPath, secrets)
|
|
248
|
-
|
|
249
|
-
return { path: providerFilePath, modelCount: models.length }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function syncPi(proxyInfo, mergedModels, paths) {
|
|
253
|
-
const models = buildProxyModels(mergedModels)
|
|
254
|
-
|
|
255
|
-
// 📖 Write models.json
|
|
256
|
-
const modelsConfig = readJson(paths.piModelsPath, { providers: {} })
|
|
257
|
-
if (!modelsConfig.providers || typeof modelsConfig.providers !== 'object') modelsConfig.providers = {}
|
|
258
|
-
// 📖 Remove old fcm-* providers
|
|
259
|
-
for (const key of Object.keys(modelsConfig.providers)) {
|
|
260
|
-
if (key.startsWith('fcm-')) delete modelsConfig.providers[key]
|
|
261
|
-
}
|
|
262
|
-
modelsConfig.providers[PROXY_PROVIDER_ID] = {
|
|
263
|
-
baseUrl: proxyInfo.baseUrl,
|
|
264
|
-
api: 'openai-completions',
|
|
265
|
-
apiKey: proxyInfo.token,
|
|
266
|
-
models: models.map(m => ({ id: m.slug, name: m.label })),
|
|
267
|
-
}
|
|
268
|
-
writeJson(paths.piModelsPath, modelsConfig)
|
|
269
|
-
|
|
270
|
-
// 📖 Write settings.json — set default to first model
|
|
271
|
-
const settingsConfig = readJson(paths.piSettingsPath, {})
|
|
272
|
-
settingsConfig.defaultProvider = PROXY_PROVIDER_ID
|
|
273
|
-
settingsConfig.defaultModel = models[0]?.slug ?? ''
|
|
274
|
-
writeJson(paths.piSettingsPath, settingsConfig)
|
|
275
|
-
|
|
276
|
-
return { path: paths.piModelsPath, modelCount: models.length }
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function syncAider(proxyInfo, mergedModels, paths) {
|
|
280
|
-
const models = buildProxyModels(mergedModels)
|
|
281
|
-
const primarySlug = models[0]?.slug ?? ''
|
|
282
|
-
const lines = [
|
|
283
|
-
'# 📖 Managed by free-coding-models — FCM Proxy V2',
|
|
284
|
-
`openai-api-base: ${proxyInfo.baseUrl}`,
|
|
285
|
-
`openai-api-key: ${proxyInfo.token}`,
|
|
286
|
-
`model: openai/${primarySlug}`,
|
|
287
|
-
'',
|
|
288
|
-
]
|
|
289
|
-
ensureDirFor(paths.aiderConfigPath)
|
|
290
|
-
if (existsSync(paths.aiderConfigPath)) {
|
|
291
|
-
try { copyFileSync(paths.aiderConfigPath, paths.aiderConfigPath + '.bak') } catch { /* best effort */ }
|
|
292
|
-
}
|
|
293
|
-
writeFileSync(paths.aiderConfigPath, lines.join('\n'))
|
|
294
|
-
return { path: paths.aiderConfigPath, modelCount: models.length }
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function syncAmp(proxyInfo, mergedModels, paths) {
|
|
298
|
-
const models = buildProxyModels(mergedModels)
|
|
299
|
-
const config = readJson(paths.ampConfigPath, {})
|
|
300
|
-
config['amp.url'] = proxyInfo.baseUrl
|
|
301
|
-
config['amp.model'] = models[0]?.slug ?? ''
|
|
302
|
-
writeJson(paths.ampConfigPath, config)
|
|
303
|
-
return { path: paths.ampConfigPath, modelCount: models.length }
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function syncQwen(proxyInfo, mergedModels, paths) {
|
|
307
|
-
const models = buildProxyModels(mergedModels)
|
|
308
|
-
const config = readJson(paths.qwenConfigPath, {})
|
|
309
|
-
if (!config.modelProviders || typeof config.modelProviders !== 'object') config.modelProviders = {}
|
|
310
|
-
if (!Array.isArray(config.modelProviders.openai)) config.modelProviders.openai = []
|
|
311
|
-
|
|
312
|
-
// 📖 Remove old FCM-managed entries
|
|
313
|
-
config.modelProviders.openai = config.modelProviders.openai.filter(
|
|
314
|
-
entry => !models.some(m => m.slug === entry?.id)
|
|
315
|
-
)
|
|
316
|
-
// 📖 Prepend proxy models
|
|
317
|
-
const newEntries = models.map(m => ({
|
|
318
|
-
id: m.slug, name: m.label, envKey: 'FCM_PROXY_API_KEY', baseUrl: proxyInfo.baseUrl,
|
|
319
|
-
}))
|
|
320
|
-
config.modelProviders.openai = [...newEntries, ...config.modelProviders.openai]
|
|
321
|
-
config.model = models[0]?.slug ?? ''
|
|
322
|
-
writeJson(paths.qwenConfigPath, config)
|
|
323
|
-
return { path: paths.qwenConfigPath, modelCount: models.length }
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function syncEnvTool(proxyInfo, mergedModels, toolMode) {
|
|
327
|
-
const home = homedir()
|
|
328
|
-
const envFilePath = join(home, `.fcm-${toolMode}-env`)
|
|
329
|
-
const models = buildProxyModels(mergedModels)
|
|
330
|
-
const primarySlug = models[0]?.slug ?? ''
|
|
331
|
-
|
|
332
|
-
const envLines = [
|
|
333
|
-
'# 📖 Managed by free-coding-models — FCM Proxy V2 (single endpoint, all models)',
|
|
334
|
-
`# 📖 ${models.length} models available through the proxy`,
|
|
335
|
-
`export OPENAI_API_KEY="${proxyInfo.token}"`,
|
|
336
|
-
`export OPENAI_BASE_URL="${proxyInfo.baseUrl}"`,
|
|
337
|
-
`export OPENAI_MODEL="${primarySlug}"`,
|
|
338
|
-
`export LLM_API_KEY="${proxyInfo.token}"`,
|
|
339
|
-
`export LLM_BASE_URL="${proxyInfo.baseUrl}"`,
|
|
340
|
-
`export LLM_MODEL="openai/${primarySlug}"`,
|
|
341
|
-
]
|
|
342
|
-
|
|
343
|
-
// 📖 Claude Code: Anthropic-specific env vars
|
|
344
|
-
if (toolMode === 'claude-code') {
|
|
345
|
-
const proxyBase = proxyInfo.baseUrl.replace(/\/v1$/, '')
|
|
346
|
-
envLines.push(`export ANTHROPIC_AUTH_TOKEN="${proxyInfo.token}"`)
|
|
347
|
-
envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
ensureDirFor(envFilePath)
|
|
351
|
-
if (existsSync(envFilePath)) {
|
|
352
|
-
try { copyFileSync(envFilePath, envFilePath + '.bak') } catch { /* best effort */ }
|
|
353
|
-
}
|
|
354
|
-
writeFileSync(envFilePath, envLines.join('\n') + '\n')
|
|
355
|
-
return { path: envFilePath, modelCount: models.length }
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* 📖 Sync FCM Proxy V2 endpoint to a specific tool's config.
|
|
362
|
-
* 📖 Writes a single `fcm-proxy` provider with ALL available models and
|
|
363
|
-
* 📖 cleans up old per-provider `fcm-*` entries in the same operation.
|
|
364
|
-
*
|
|
365
|
-
* @param {string} toolMode — tool key (e.g. 'opencode', 'claude-code', 'goose')
|
|
366
|
-
* @param {{ baseUrl: string, token: string }} proxyInfo — proxy endpoint info
|
|
367
|
-
* @param {Array} mergedModels — output of buildMergedModels() with { slug, label, ctx }
|
|
368
|
-
* @returns {{ success: boolean, path?: string, modelCount?: number, error?: string }}
|
|
369
|
-
*/
|
|
370
|
-
export function syncProxyToTool(toolMode, proxyInfo, mergedModels) {
|
|
371
|
-
const canonical = resolveProxySyncToolMode(toolMode)
|
|
372
|
-
if (!canonical) {
|
|
373
|
-
return { success: false, error: `Tool '${toolMode}' does not support proxy sync` }
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
const paths = getDefaultPaths()
|
|
378
|
-
let result
|
|
379
|
-
|
|
380
|
-
switch (canonical) {
|
|
381
|
-
case 'opencode':
|
|
382
|
-
result = syncOpenCode(proxyInfo, mergedModels)
|
|
383
|
-
break
|
|
384
|
-
case 'openclaw':
|
|
385
|
-
result = syncOpenClaw(proxyInfo, mergedModels, paths)
|
|
386
|
-
break
|
|
387
|
-
case 'crush':
|
|
388
|
-
result = syncCrush(proxyInfo, mergedModels, paths)
|
|
389
|
-
break
|
|
390
|
-
case 'goose':
|
|
391
|
-
result = syncGoose(proxyInfo, mergedModels, paths)
|
|
392
|
-
break
|
|
393
|
-
case 'pi':
|
|
394
|
-
result = syncPi(proxyInfo, mergedModels, paths)
|
|
395
|
-
break
|
|
396
|
-
case 'aider':
|
|
397
|
-
result = syncAider(proxyInfo, mergedModels, paths)
|
|
398
|
-
break
|
|
399
|
-
case 'amp':
|
|
400
|
-
result = syncAmp(proxyInfo, mergedModels, paths)
|
|
401
|
-
break
|
|
402
|
-
case 'qwen':
|
|
403
|
-
result = syncQwen(proxyInfo, mergedModels, paths)
|
|
404
|
-
break
|
|
405
|
-
case 'claude-code':
|
|
406
|
-
case 'codex':
|
|
407
|
-
case 'openhands':
|
|
408
|
-
result = syncEnvTool(proxyInfo, mergedModels, canonical)
|
|
409
|
-
break
|
|
410
|
-
default:
|
|
411
|
-
return { success: false, error: `Unknown tool: ${toolMode}` }
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return { success: true, ...result }
|
|
415
|
-
} catch (err) {
|
|
416
|
-
return { success: false, error: err.message }
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* 📖 Remove all FCM-managed entries from a tool's config.
|
|
422
|
-
* 📖 Removes both old per-provider `fcm-*` and the unified `fcm-proxy` entries.
|
|
423
|
-
*
|
|
424
|
-
* @param {string} toolMode
|
|
425
|
-
* @returns {{ success: boolean, error?: string }}
|
|
426
|
-
*/
|
|
427
|
-
export function cleanupToolConfig(toolMode) {
|
|
428
|
-
const canonical = resolveProxySyncToolMode(toolMode)
|
|
429
|
-
if (!canonical) {
|
|
430
|
-
return { success: false, error: `Tool '${toolMode}' does not support proxy cleanup` }
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
try {
|
|
434
|
-
const paths = getDefaultPaths()
|
|
435
|
-
|
|
436
|
-
switch (canonical) {
|
|
437
|
-
case 'opencode': {
|
|
438
|
-
const result = cleanupOpenCodeProxyConfig()
|
|
439
|
-
// 📖 Also clean old fcm-{provider} entries beyond fcm-proxy
|
|
440
|
-
try {
|
|
441
|
-
const oc = JSON.parse(readFileSync(paths.opencodeConfigPath, 'utf8'))
|
|
442
|
-
if (oc.provider) {
|
|
443
|
-
let changed = false
|
|
444
|
-
for (const key of Object.keys(oc.provider)) {
|
|
445
|
-
if (key.startsWith('fcm-')) { delete oc.provider[key]; changed = true }
|
|
446
|
-
}
|
|
447
|
-
if (changed) writeJson(paths.opencodeConfigPath, oc)
|
|
448
|
-
}
|
|
449
|
-
} catch { /* best effort */ }
|
|
450
|
-
return { success: true, ...result }
|
|
451
|
-
}
|
|
452
|
-
case 'openclaw': {
|
|
453
|
-
const config = readJson(paths.openclawConfigPath, {})
|
|
454
|
-
if (config.models?.providers) {
|
|
455
|
-
for (const key of Object.keys(config.models.providers)) {
|
|
456
|
-
if (key.startsWith('fcm-')) delete config.models.providers[key]
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
if (config.agents?.defaults?.models) {
|
|
460
|
-
for (const ref of Object.keys(config.agents.defaults.models)) {
|
|
461
|
-
if (ref.startsWith('fcm-')) delete config.agents.defaults.models[ref]
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
writeJson(paths.openclawConfigPath, config)
|
|
465
|
-
return { success: true }
|
|
466
|
-
}
|
|
467
|
-
case 'crush': {
|
|
468
|
-
const config = readJson(paths.crushConfigPath, {})
|
|
469
|
-
if (config.providers) {
|
|
470
|
-
for (const key of Object.keys(config.providers)) {
|
|
471
|
-
if (key.startsWith('fcm-')) delete config.providers[key]
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
writeJson(paths.crushConfigPath, config)
|
|
475
|
-
return { success: true }
|
|
476
|
-
}
|
|
477
|
-
case 'goose': {
|
|
478
|
-
// 📖 Remove all fcm-* provider files
|
|
479
|
-
try {
|
|
480
|
-
if (existsSync(paths.gooseProvidersDir)) {
|
|
481
|
-
for (const f of readdirSync(paths.gooseProvidersDir)) {
|
|
482
|
-
if (f.startsWith('fcm-')) {
|
|
483
|
-
try { unlinkSync(join(paths.gooseProvidersDir, f)) } catch { /* best effort */ }
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
} catch { /* best effort */ }
|
|
488
|
-
// 📖 Remove FCM secrets
|
|
489
|
-
const secrets = readSimpleYamlMap(paths.gooseSecretsPath)
|
|
490
|
-
for (const key of Object.keys(secrets)) {
|
|
491
|
-
if (key.startsWith('FCM_') && key.endsWith('_API_KEY')) delete secrets[key]
|
|
492
|
-
}
|
|
493
|
-
writeSimpleYamlMap(paths.gooseSecretsPath, secrets)
|
|
494
|
-
return { success: true }
|
|
495
|
-
}
|
|
496
|
-
case 'pi': {
|
|
497
|
-
const modelsConfig = readJson(paths.piModelsPath, { providers: {} })
|
|
498
|
-
if (modelsConfig.providers) {
|
|
499
|
-
for (const key of Object.keys(modelsConfig.providers)) {
|
|
500
|
-
if (key.startsWith('fcm-')) delete modelsConfig.providers[key]
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
writeJson(paths.piModelsPath, modelsConfig)
|
|
504
|
-
return { success: true }
|
|
505
|
-
}
|
|
506
|
-
case 'aider': {
|
|
507
|
-
// 📖 Only remove if managed by FCM
|
|
508
|
-
try {
|
|
509
|
-
if (existsSync(paths.aiderConfigPath)) {
|
|
510
|
-
const content = readFileSync(paths.aiderConfigPath, 'utf8')
|
|
511
|
-
if (content.includes('Managed by free-coding-models')) {
|
|
512
|
-
unlinkSync(paths.aiderConfigPath)
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
} catch { /* best effort */ }
|
|
516
|
-
return { success: true }
|
|
517
|
-
}
|
|
518
|
-
case 'amp': {
|
|
519
|
-
const config = readJson(paths.ampConfigPath, {})
|
|
520
|
-
delete config['amp.url']
|
|
521
|
-
delete config['amp.model']
|
|
522
|
-
writeJson(paths.ampConfigPath, config)
|
|
523
|
-
return { success: true }
|
|
524
|
-
}
|
|
525
|
-
case 'qwen': {
|
|
526
|
-
const config = readJson(paths.qwenConfigPath, {})
|
|
527
|
-
if (Array.isArray(config.modelProviders?.openai)) {
|
|
528
|
-
config.modelProviders.openai = config.modelProviders.openai.filter(
|
|
529
|
-
entry => entry?.envKey !== 'FCM_PROXY_API_KEY'
|
|
530
|
-
)
|
|
531
|
-
}
|
|
532
|
-
writeJson(paths.qwenConfigPath, config)
|
|
533
|
-
return { success: true }
|
|
534
|
-
}
|
|
535
|
-
case 'claude-code':
|
|
536
|
-
case 'codex':
|
|
537
|
-
case 'openhands': {
|
|
538
|
-
const envFilePath = join(homedir(), `.fcm-${canonical}-env`)
|
|
539
|
-
try {
|
|
540
|
-
if (existsSync(envFilePath)) {
|
|
541
|
-
unlinkSync(envFilePath)
|
|
542
|
-
}
|
|
543
|
-
} catch { /* best effort */ }
|
|
544
|
-
return { success: true }
|
|
545
|
-
}
|
|
546
|
-
default:
|
|
547
|
-
return { success: false, error: `Unknown tool: ${toolMode}` }
|
|
548
|
-
}
|
|
549
|
-
} catch (err) {
|
|
550
|
-
return { success: false, error: err.message }
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* 📖 Returns the list of tools that support proxy sync.
|
|
556
|
-
* 📖 Enriched with display metadata from tool-metadata.js.
|
|
557
|
-
*
|
|
558
|
-
* @returns {Array<{ key: string, label: string, emoji: string }>}
|
|
559
|
-
*/
|
|
560
|
-
export function getProxySyncableTools() {
|
|
561
|
-
return PROXY_SYNCABLE_TOOLS.map(key => {
|
|
562
|
-
const meta = getToolMeta(key)
|
|
563
|
-
return { key, label: meta.label, emoji: meta.emoji }
|
|
564
|
-
})
|
|
565
|
-
}
|
package/src/proxy-topology.js
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file src/proxy-topology.js
|
|
3
|
-
* @description Builds the proxy account topology from config + merged models.
|
|
4
|
-
*
|
|
5
|
-
* 📖 Extracted from opencode.js so the standalone daemon can reuse the same
|
|
6
|
-
* topology builder without importing TUI-specific modules (chalk, render-table).
|
|
7
|
-
*
|
|
8
|
-
* 📖 The topology is an array of "account" objects — one per API key per model.
|
|
9
|
-
* The proxy server uses these accounts for multi-key rotation and load balancing.
|
|
10
|
-
*
|
|
11
|
-
* @functions
|
|
12
|
-
* → buildProxyTopologyFromConfig(fcmConfig, mergedModels, sources) — build accounts + proxyModels + Anthropic family routing
|
|
13
|
-
* → buildMergedModelsForDaemon() — standalone helper to build merged models without TUI
|
|
14
|
-
*
|
|
15
|
-
* @exports buildProxyTopologyFromConfig, buildMergedModelsForDaemon
|
|
16
|
-
* @see src/opencode.js — TUI-side proxy lifecycle that delegates here
|
|
17
|
-
* @see bin/fcm-proxy-daemon.js — standalone daemon that uses this directly
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { resolveApiKeys, getProxySettings } from './config.js'
|
|
21
|
-
import { resolveCloudflareUrl } from './ping.js'
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* 📖 Build the account list and proxy model catalog from config + merged models.
|
|
25
|
-
*
|
|
26
|
-
* Each account represents one API key for one model on one provider.
|
|
27
|
-
* The proxy uses this list for rotation, sticky sessions, and failover.
|
|
28
|
-
*
|
|
29
|
-
* @param {object} fcmConfig — live config from loadConfig()
|
|
30
|
-
* @param {Array} mergedModels — output of buildMergedModels(MODELS)
|
|
31
|
-
* @param {object} sourcesMap — the sources object keyed by providerKey
|
|
32
|
-
* @returns {{ accounts: Array, proxyModels: Record<string, { name: string }>, anthropicRouting: { model: string|null, modelOpus: string|null, modelSonnet: string|null, modelHaiku: string|null } }}
|
|
33
|
-
*/
|
|
34
|
-
export function buildProxyTopologyFromConfig(fcmConfig, mergedModels, sourcesMap) {
|
|
35
|
-
const accounts = []
|
|
36
|
-
const proxyModels = {}
|
|
37
|
-
|
|
38
|
-
for (const merged of mergedModels) {
|
|
39
|
-
proxyModels[merged.slug] = { name: merged.label }
|
|
40
|
-
|
|
41
|
-
for (const providerEntry of merged.providers) {
|
|
42
|
-
// 📖 Trim whitespace from API keys — common copy-paste error that causes silent auth failures
|
|
43
|
-
const keys = resolveApiKeys(fcmConfig, providerEntry.providerKey)
|
|
44
|
-
.map(k => typeof k === 'string' ? k.trim() : k)
|
|
45
|
-
.filter(Boolean)
|
|
46
|
-
const providerSource = sourcesMap[providerEntry.providerKey]
|
|
47
|
-
if (!providerSource) continue
|
|
48
|
-
|
|
49
|
-
const rawUrl = resolveCloudflareUrl(providerSource.url || '')
|
|
50
|
-
// 📖 Skip provider if URL resolution fails (e.g. undefined or empty URL)
|
|
51
|
-
if (!rawUrl) continue
|
|
52
|
-
const baseUrl = rawUrl.replace(/\/chat\/completions$/, '')
|
|
53
|
-
|
|
54
|
-
keys.forEach((apiKey, keyIdx) => {
|
|
55
|
-
accounts.push({
|
|
56
|
-
id: `${providerEntry.providerKey}/${merged.slug}/${keyIdx}`,
|
|
57
|
-
providerKey: providerEntry.providerKey,
|
|
58
|
-
proxyModelId: merged.slug,
|
|
59
|
-
modelId: providerEntry.modelId,
|
|
60
|
-
url: baseUrl,
|
|
61
|
-
apiKey,
|
|
62
|
-
})
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
accounts,
|
|
69
|
-
proxyModels,
|
|
70
|
-
// 📖 Mirror Claude proxy: proxy-side Claude family routing is config-driven.
|
|
71
|
-
anthropicRouting: getProxySettings(fcmConfig).anthropicRouting,
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* 📖 Build merged models from sources without TUI dependencies.
|
|
77
|
-
* 📖 Used by the standalone daemon to get the full model catalog.
|
|
78
|
-
*
|
|
79
|
-
* @returns {Promise<Array>} merged model list
|
|
80
|
-
*/
|
|
81
|
-
export async function buildMergedModelsForDaemon() {
|
|
82
|
-
const { MODELS } = await import('../sources.js')
|
|
83
|
-
const { buildMergedModels } = await import('./model-merger.js')
|
|
84
|
-
return buildMergedModels(MODELS)
|
|
85
|
-
}
|