free-coding-models 0.3.11 → 0.3.13

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/src/proxy-sync.js DELETED
@@ -1,591 +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
- // 📖 Ensures the env file is sourced in the user's shell profile (.zshrc, .bashrc, .bash_profile)
36
- // 📖 Only adds the source line if not already present — idempotent
37
- function ensureShellSourceLine(envFilePath) {
38
- const home = homedir()
39
- const candidates = ['.zshrc', '.bashrc', '.bash_profile']
40
- let profilePath = null
41
- for (const c of candidates) {
42
- const p = join(home, c)
43
- if (existsSync(p)) { profilePath = p; break }
44
- }
45
- if (!profilePath) return // 📖 No shell profile found — skip silently
46
-
47
- try {
48
- const content = readFileSync(profilePath, 'utf8')
49
- if (content.includes(envFilePath)) return // 📖 Already sourced
50
- const sourceLine = `\n# 📖 FCM Proxy — Claude Code env vars\n[ -f "${envFilePath}" ] && source "${envFilePath}"\n`
51
- writeFileSync(profilePath, content + sourceLine)
52
- } catch { /* best effort */ }
53
- }
54
-
55
- // 📖 Tools that support proxy sync (have base URL + API key config)
56
- // 📖 Gemini is excluded — it only stores a model name, no URL/key fields.
57
- // 📖 Claude proxy integration is
58
- // 📖 runtime-only now, with fake Claude ids handled by the proxy itself.
59
- export const PROXY_SYNCABLE_TOOLS = [
60
- 'opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose', 'pi',
61
- 'aider', 'amp', 'qwen', 'codex', 'openhands', 'claude-code',
62
- ]
63
-
64
- const PROXY_SYNCABLE_CANONICAL = new Set(PROXY_SYNCABLE_TOOLS.map(tool => tool === 'opencode-desktop' ? 'opencode' : tool))
65
-
66
- // ─── Shared helpers ──────────────────────────────────────────────────────────
67
-
68
- function getDefaultPaths() {
69
- const home = homedir()
70
- return {
71
- opencodeConfigPath: join(home, '.config', 'opencode', 'opencode.json'),
72
- openclawConfigPath: join(home, '.openclaw', 'openclaw.json'),
73
- crushConfigPath: join(home, '.config', 'crush', 'crush.json'),
74
- gooseProvidersDir: join(home, '.config', 'goose', 'custom_providers'),
75
- gooseSecretsPath: join(home, '.config', 'goose', 'secrets.yaml'),
76
- piModelsPath: join(home, '.pi', 'agent', 'models.json'),
77
- piSettingsPath: join(home, '.pi', 'agent', 'settings.json'),
78
- aiderConfigPath: join(home, '.aider.conf.yml'),
79
- ampConfigPath: join(home, '.config', 'amp', 'settings.json'),
80
- qwenConfigPath: join(home, '.qwen', 'settings.json'),
81
- }
82
- }
83
-
84
- function ensureDirFor(filePath) {
85
- const dir = join(filePath, '..')
86
- mkdirSync(dir, { recursive: true })
87
- }
88
-
89
- function readJson(filePath, fallback = {}) {
90
- try {
91
- if (existsSync(filePath)) return JSON.parse(readFileSync(filePath, 'utf8'))
92
- } catch { /* corrupted — start fresh */ }
93
- return { ...fallback }
94
- }
95
-
96
- function writeJson(filePath, data) {
97
- ensureDirFor(filePath)
98
- const backupPath = filePath + '.bak'
99
- if (existsSync(filePath)) {
100
- try { copyFileSync(filePath, backupPath) } catch { /* best effort */ }
101
- }
102
- writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n')
103
- return backupPath
104
- }
105
-
106
- function readSimpleYamlMap(filePath) {
107
- if (!existsSync(filePath)) return {}
108
- const out = {}
109
- const lines = readFileSync(filePath, 'utf8').split(/\r?\n/)
110
- for (const line of lines) {
111
- if (!line.trim() || line.trim().startsWith('#')) continue
112
- const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
113
- if (!match) continue
114
- let value = match[2].trim()
115
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
116
- value = value.slice(1, -1)
117
- }
118
- out[match[1]] = value
119
- }
120
- return out
121
- }
122
-
123
- function writeSimpleYamlMap(filePath, entries) {
124
- ensureDirFor(filePath)
125
- const lines = Object.keys(entries).sort().map(key => `${key}: ${JSON.stringify(String(entries[key] ?? ''))}`)
126
- writeFileSync(filePath, lines.join('\n') + '\n')
127
- }
128
-
129
- // 📖 Build proxy model list from mergedModels array
130
- // 📖 Each model has { slug, label, ctx } — slug is the proxy model ID
131
- function buildProxyModels(mergedModels) {
132
- return mergedModels.map(m => ({ slug: m.slug, label: m.label, ctx: m.ctx || '128k' }))
133
- }
134
-
135
- function parseContextWindow(ctx) {
136
- if (typeof ctx !== 'string' || !ctx.trim()) return 128000
137
- const trimmed = ctx.trim().toLowerCase()
138
- const multiplier = trimmed.endsWith('m') ? 1_000_000 : trimmed.endsWith('k') ? 1_000 : 1
139
- const numeric = Number.parseFloat(trimmed.replace(/[mk]$/i, ''))
140
- if (!Number.isFinite(numeric) || numeric <= 0) return 128000
141
- return Math.round(numeric * multiplier)
142
- }
143
-
144
- function getDefaultMaxTokens(contextWindow) {
145
- return Math.max(4096, Math.min(contextWindow, 32768))
146
- }
147
-
148
- export function resolveProxySyncToolMode(toolMode) {
149
- if (typeof toolMode !== 'string' || toolMode.length === 0) return null
150
- const canonical = toolMode === 'opencode-desktop' ? 'opencode' : toolMode
151
- return PROXY_SYNCABLE_CANONICAL.has(canonical) ? canonical : null
152
- }
153
-
154
- // ─── Per-tool sync functions ─────────────────────────────────────────────────
155
- // 📖 Each writes a single `fcm-proxy` provider entry with ALL models
156
-
157
- function syncOpenCode(proxyInfo, mergedModels) {
158
- // 📖 Delegate to the existing OpenCode sync module
159
- return syncToOpenCode(null, null, mergedModels, proxyInfo)
160
- }
161
-
162
- function syncOpenClaw(proxyInfo, mergedModels, paths) {
163
- const filePath = paths.openclawConfigPath
164
- const config = readJson(filePath, {})
165
- const models = buildProxyModels(mergedModels)
166
-
167
- if (!config.models || typeof config.models !== 'object') config.models = {}
168
- if (config.models.mode !== 'replace') config.models.mode = 'merge'
169
- if (!config.models.providers || typeof config.models.providers !== 'object') config.models.providers = {}
170
- if (!config.agents || typeof config.agents !== 'object') config.agents = {}
171
- if (!config.agents.defaults || typeof config.agents.defaults !== 'object') config.agents.defaults = {}
172
- if (!config.agents.defaults.models || typeof config.agents.defaults.models !== 'object') config.agents.defaults.models = {}
173
-
174
- // 📖 Remove old fcm-* providers (direct installs vestiges)
175
- for (const key of Object.keys(config.models.providers)) {
176
- if (key.startsWith('fcm-')) delete config.models.providers[key]
177
- }
178
- for (const modelRef of Object.keys(config.agents.defaults.models)) {
179
- if (modelRef.startsWith('fcm-')) delete config.agents.defaults.models[modelRef]
180
- }
181
-
182
- // 📖 Write single fcm-proxy provider with all models
183
- config.models.providers[PROXY_PROVIDER_ID] = {
184
- baseUrl: proxyInfo.baseUrl,
185
- apiKey: proxyInfo.token,
186
- api: 'openai-completions',
187
- models: models.map(m => {
188
- const contextWindow = parseContextWindow(m.ctx)
189
- return {
190
- id: m.slug, name: m.label, api: 'openai-completions', reasoning: false,
191
- input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
192
- contextWindow, maxTokens: getDefaultMaxTokens(contextWindow),
193
- }
194
- }),
195
- }
196
- for (const m of models) {
197
- config.agents.defaults.models[`${PROXY_PROVIDER_ID}/${m.slug}`] = {}
198
- }
199
-
200
- writeJson(filePath, config)
201
- return { path: filePath, modelCount: models.length }
202
- }
203
-
204
- function syncCrush(proxyInfo, mergedModels, paths) {
205
- const filePath = paths.crushConfigPath
206
- const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
207
- if (!config.providers || typeof config.providers !== 'object') config.providers = {}
208
- const models = buildProxyModels(mergedModels)
209
-
210
- // 📖 Remove old fcm-* providers
211
- for (const key of Object.keys(config.providers)) {
212
- if (key.startsWith('fcm-')) delete config.providers[key]
213
- }
214
-
215
- config.providers[PROXY_PROVIDER_ID] = {
216
- name: 'FCM Proxy V2',
217
- type: 'openai-compat',
218
- base_url: proxyInfo.baseUrl,
219
- api_key: proxyInfo.token,
220
- models: models.map(m => {
221
- const contextWindow = parseContextWindow(m.ctx)
222
- return { id: m.slug, name: m.label, context_window: contextWindow, default_max_tokens: getDefaultMaxTokens(contextWindow) }
223
- }),
224
- }
225
-
226
- writeJson(filePath, config)
227
- return { path: filePath, modelCount: models.length }
228
- }
229
-
230
- function syncGoose(proxyInfo, mergedModels, paths) {
231
- const models = buildProxyModels(mergedModels)
232
- const providerFilePath = join(paths.gooseProvidersDir, `${PROXY_PROVIDER_ID}.json`)
233
-
234
- // 📖 Remove old fcm-* provider files
235
- try {
236
- if (existsSync(paths.gooseProvidersDir)) {
237
- for (const f of readdirSync(paths.gooseProvidersDir)) {
238
- if (f.startsWith('fcm-') && f !== `${PROXY_PROVIDER_ID}.json`) {
239
- try { unlinkSync(join(paths.gooseProvidersDir, f)) } catch { /* best effort */ }
240
- }
241
- }
242
- }
243
- } catch { /* best effort */ }
244
-
245
- const providerConfig = {
246
- name: PROXY_PROVIDER_ID,
247
- engine: 'openai',
248
- display_name: 'FCM Proxy V2',
249
- description: 'Managed by free-coding-models — single endpoint for all models',
250
- api_key_env: 'FCM_PROXY_API_KEY',
251
- base_url: proxyInfo.baseUrl,
252
- models: models.map(m => ({ name: m.slug, context_limit: parseContextWindow(m.ctx) })),
253
- supports_streaming: true,
254
- requires_auth: true,
255
- }
256
-
257
- writeJson(providerFilePath, providerConfig)
258
-
259
- // 📖 Write secret + clean old fcm-* secrets
260
- const secrets = readSimpleYamlMap(paths.gooseSecretsPath)
261
- for (const key of Object.keys(secrets)) {
262
- if (key.startsWith('FCM_') && key.endsWith('_API_KEY') && key !== 'FCM_PROXY_API_KEY') {
263
- delete secrets[key]
264
- }
265
- }
266
- secrets.FCM_PROXY_API_KEY = proxyInfo.token
267
- writeSimpleYamlMap(paths.gooseSecretsPath, secrets)
268
-
269
- return { path: providerFilePath, modelCount: models.length }
270
- }
271
-
272
- function syncPi(proxyInfo, mergedModels, paths) {
273
- const models = buildProxyModels(mergedModels)
274
-
275
- // 📖 Write models.json
276
- const modelsConfig = readJson(paths.piModelsPath, { providers: {} })
277
- if (!modelsConfig.providers || typeof modelsConfig.providers !== 'object') modelsConfig.providers = {}
278
- // 📖 Remove old fcm-* providers
279
- for (const key of Object.keys(modelsConfig.providers)) {
280
- if (key.startsWith('fcm-')) delete modelsConfig.providers[key]
281
- }
282
- modelsConfig.providers[PROXY_PROVIDER_ID] = {
283
- baseUrl: proxyInfo.baseUrl,
284
- api: 'openai-completions',
285
- apiKey: proxyInfo.token,
286
- models: models.map(m => ({ id: m.slug, name: m.label })),
287
- }
288
- writeJson(paths.piModelsPath, modelsConfig)
289
-
290
- // 📖 Write settings.json — set default to first model
291
- const settingsConfig = readJson(paths.piSettingsPath, {})
292
- settingsConfig.defaultProvider = PROXY_PROVIDER_ID
293
- settingsConfig.defaultModel = models[0]?.slug ?? ''
294
- writeJson(paths.piSettingsPath, settingsConfig)
295
-
296
- return { path: paths.piModelsPath, modelCount: models.length }
297
- }
298
-
299
- function syncAider(proxyInfo, mergedModels, paths) {
300
- const models = buildProxyModels(mergedModels)
301
- const primarySlug = models[0]?.slug ?? ''
302
- const lines = [
303
- '# 📖 Managed by free-coding-models — FCM Proxy V2',
304
- `openai-api-base: ${proxyInfo.baseUrl}`,
305
- `openai-api-key: ${proxyInfo.token}`,
306
- `model: openai/${primarySlug}`,
307
- '',
308
- ]
309
- ensureDirFor(paths.aiderConfigPath)
310
- if (existsSync(paths.aiderConfigPath)) {
311
- try { copyFileSync(paths.aiderConfigPath, paths.aiderConfigPath + '.bak') } catch { /* best effort */ }
312
- }
313
- writeFileSync(paths.aiderConfigPath, lines.join('\n'))
314
- return { path: paths.aiderConfigPath, modelCount: models.length }
315
- }
316
-
317
- function syncAmp(proxyInfo, mergedModels, paths) {
318
- const models = buildProxyModels(mergedModels)
319
- const config = readJson(paths.ampConfigPath, {})
320
- config['amp.url'] = proxyInfo.baseUrl
321
- config['amp.model'] = models[0]?.slug ?? ''
322
- writeJson(paths.ampConfigPath, config)
323
- return { path: paths.ampConfigPath, modelCount: models.length }
324
- }
325
-
326
- function syncQwen(proxyInfo, mergedModels, paths) {
327
- const models = buildProxyModels(mergedModels)
328
- const config = readJson(paths.qwenConfigPath, {})
329
- if (!config.modelProviders || typeof config.modelProviders !== 'object') config.modelProviders = {}
330
- if (!Array.isArray(config.modelProviders.openai)) config.modelProviders.openai = []
331
-
332
- // 📖 Remove old FCM-managed entries
333
- config.modelProviders.openai = config.modelProviders.openai.filter(
334
- entry => !models.some(m => m.slug === entry?.id)
335
- )
336
- // 📖 Prepend proxy models
337
- const newEntries = models.map(m => ({
338
- id: m.slug, name: m.label, envKey: 'FCM_PROXY_API_KEY', baseUrl: proxyInfo.baseUrl,
339
- }))
340
- config.modelProviders.openai = [...newEntries, ...config.modelProviders.openai]
341
- config.model = models[0]?.slug ?? ''
342
- writeJson(paths.qwenConfigPath, config)
343
- return { path: paths.qwenConfigPath, modelCount: models.length }
344
- }
345
-
346
- function syncEnvTool(proxyInfo, mergedModels, toolMode) {
347
- const home = homedir()
348
- const envFilePath = join(home, `.fcm-${toolMode}-env`)
349
- const models = buildProxyModels(mergedModels)
350
- const primarySlug = models[0]?.slug ?? ''
351
-
352
- const envLines = [
353
- '# 📖 Managed by free-coding-models — FCM Proxy V2 (single endpoint, all models)',
354
- `# 📖 ${models.length} models available through the proxy`,
355
- `export OPENAI_API_KEY="${proxyInfo.token}"`,
356
- `export OPENAI_BASE_URL="${proxyInfo.baseUrl}"`,
357
- `export OPENAI_MODEL="${primarySlug}"`,
358
- `export LLM_API_KEY="${proxyInfo.token}"`,
359
- `export LLM_BASE_URL="${proxyInfo.baseUrl}"`,
360
- `export LLM_MODEL="openai/${primarySlug}"`,
361
- ]
362
-
363
- // 📖 Claude Code: Anthropic-specific env vars
364
- if (toolMode === 'claude-code') {
365
- const proxyBase = proxyInfo.baseUrl.replace(/\/v1$/, '')
366
- envLines.push(`export ANTHROPIC_API_KEY="${proxyInfo.token}"`)
367
- envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
368
- }
369
-
370
- ensureDirFor(envFilePath)
371
- if (existsSync(envFilePath)) {
372
- try { copyFileSync(envFilePath, envFilePath + '.bak') } catch { /* best effort */ }
373
- }
374
- writeFileSync(envFilePath, envLines.join('\n') + '\n')
375
-
376
- // 📖 Auto-source the env file in the shell profile so Claude Code picks it up
377
- if (toolMode === 'claude-code') {
378
- ensureShellSourceLine(envFilePath)
379
- }
380
-
381
- return { path: envFilePath, modelCount: models.length }
382
- }
383
-
384
- // ─── Public API ──────────────────────────────────────────────────────────────
385
-
386
- /**
387
- * 📖 Sync FCM Proxy V2 endpoint to a specific tool's config.
388
- * 📖 Writes a single `fcm-proxy` provider with ALL available models and
389
- * 📖 cleans up old per-provider `fcm-*` entries in the same operation.
390
- *
391
- * @param {string} toolMode — tool key (e.g. 'opencode', 'claude-code', 'goose')
392
- * @param {{ baseUrl: string, token: string }} proxyInfo — proxy endpoint info
393
- * @param {Array} mergedModels — output of buildMergedModels() with { slug, label, ctx }
394
- * @returns {{ success: boolean, path?: string, modelCount?: number, error?: string }}
395
- */
396
- export function syncProxyToTool(toolMode, proxyInfo, mergedModels) {
397
- const canonical = resolveProxySyncToolMode(toolMode)
398
- if (!canonical) {
399
- return { success: false, error: `Tool '${toolMode}' does not support proxy sync` }
400
- }
401
-
402
- try {
403
- const paths = getDefaultPaths()
404
- let result
405
-
406
- switch (canonical) {
407
- case 'opencode':
408
- result = syncOpenCode(proxyInfo, mergedModels)
409
- break
410
- case 'openclaw':
411
- result = syncOpenClaw(proxyInfo, mergedModels, paths)
412
- break
413
- case 'crush':
414
- result = syncCrush(proxyInfo, mergedModels, paths)
415
- break
416
- case 'goose':
417
- result = syncGoose(proxyInfo, mergedModels, paths)
418
- break
419
- case 'pi':
420
- result = syncPi(proxyInfo, mergedModels, paths)
421
- break
422
- case 'aider':
423
- result = syncAider(proxyInfo, mergedModels, paths)
424
- break
425
- case 'amp':
426
- result = syncAmp(proxyInfo, mergedModels, paths)
427
- break
428
- case 'qwen':
429
- result = syncQwen(proxyInfo, mergedModels, paths)
430
- break
431
- case 'claude-code':
432
- case 'codex':
433
- case 'openhands':
434
- result = syncEnvTool(proxyInfo, mergedModels, canonical)
435
- break
436
- default:
437
- return { success: false, error: `Unknown tool: ${toolMode}` }
438
- }
439
-
440
- return { success: true, ...result }
441
- } catch (err) {
442
- return { success: false, error: err.message }
443
- }
444
- }
445
-
446
- /**
447
- * 📖 Remove all FCM-managed entries from a tool's config.
448
- * 📖 Removes both old per-provider `fcm-*` and the unified `fcm-proxy` entries.
449
- *
450
- * @param {string} toolMode
451
- * @returns {{ success: boolean, error?: string }}
452
- */
453
- export function cleanupToolConfig(toolMode) {
454
- const canonical = resolveProxySyncToolMode(toolMode)
455
- if (!canonical) {
456
- return { success: false, error: `Tool '${toolMode}' does not support proxy cleanup` }
457
- }
458
-
459
- try {
460
- const paths = getDefaultPaths()
461
-
462
- switch (canonical) {
463
- case 'opencode': {
464
- const result = cleanupOpenCodeProxyConfig()
465
- // 📖 Also clean old fcm-{provider} entries beyond fcm-proxy
466
- try {
467
- const oc = JSON.parse(readFileSync(paths.opencodeConfigPath, 'utf8'))
468
- if (oc.provider) {
469
- let changed = false
470
- for (const key of Object.keys(oc.provider)) {
471
- if (key.startsWith('fcm-')) { delete oc.provider[key]; changed = true }
472
- }
473
- if (changed) writeJson(paths.opencodeConfigPath, oc)
474
- }
475
- } catch { /* best effort */ }
476
- return { success: true, ...result }
477
- }
478
- case 'openclaw': {
479
- const config = readJson(paths.openclawConfigPath, {})
480
- if (config.models?.providers) {
481
- for (const key of Object.keys(config.models.providers)) {
482
- if (key.startsWith('fcm-')) delete config.models.providers[key]
483
- }
484
- }
485
- if (config.agents?.defaults?.models) {
486
- for (const ref of Object.keys(config.agents.defaults.models)) {
487
- if (ref.startsWith('fcm-')) delete config.agents.defaults.models[ref]
488
- }
489
- }
490
- writeJson(paths.openclawConfigPath, config)
491
- return { success: true }
492
- }
493
- case 'crush': {
494
- const config = readJson(paths.crushConfigPath, {})
495
- if (config.providers) {
496
- for (const key of Object.keys(config.providers)) {
497
- if (key.startsWith('fcm-')) delete config.providers[key]
498
- }
499
- }
500
- writeJson(paths.crushConfigPath, config)
501
- return { success: true }
502
- }
503
- case 'goose': {
504
- // 📖 Remove all fcm-* provider files
505
- try {
506
- if (existsSync(paths.gooseProvidersDir)) {
507
- for (const f of readdirSync(paths.gooseProvidersDir)) {
508
- if (f.startsWith('fcm-')) {
509
- try { unlinkSync(join(paths.gooseProvidersDir, f)) } catch { /* best effort */ }
510
- }
511
- }
512
- }
513
- } catch { /* best effort */ }
514
- // 📖 Remove FCM secrets
515
- const secrets = readSimpleYamlMap(paths.gooseSecretsPath)
516
- for (const key of Object.keys(secrets)) {
517
- if (key.startsWith('FCM_') && key.endsWith('_API_KEY')) delete secrets[key]
518
- }
519
- writeSimpleYamlMap(paths.gooseSecretsPath, secrets)
520
- return { success: true }
521
- }
522
- case 'pi': {
523
- const modelsConfig = readJson(paths.piModelsPath, { providers: {} })
524
- if (modelsConfig.providers) {
525
- for (const key of Object.keys(modelsConfig.providers)) {
526
- if (key.startsWith('fcm-')) delete modelsConfig.providers[key]
527
- }
528
- }
529
- writeJson(paths.piModelsPath, modelsConfig)
530
- return { success: true }
531
- }
532
- case 'aider': {
533
- // 📖 Only remove if managed by FCM
534
- try {
535
- if (existsSync(paths.aiderConfigPath)) {
536
- const content = readFileSync(paths.aiderConfigPath, 'utf8')
537
- if (content.includes('Managed by free-coding-models')) {
538
- unlinkSync(paths.aiderConfigPath)
539
- }
540
- }
541
- } catch { /* best effort */ }
542
- return { success: true }
543
- }
544
- case 'amp': {
545
- const config = readJson(paths.ampConfigPath, {})
546
- delete config['amp.url']
547
- delete config['amp.model']
548
- writeJson(paths.ampConfigPath, config)
549
- return { success: true }
550
- }
551
- case 'qwen': {
552
- const config = readJson(paths.qwenConfigPath, {})
553
- if (Array.isArray(config.modelProviders?.openai)) {
554
- config.modelProviders.openai = config.modelProviders.openai.filter(
555
- entry => entry?.envKey !== 'FCM_PROXY_API_KEY'
556
- )
557
- }
558
- writeJson(paths.qwenConfigPath, config)
559
- return { success: true }
560
- }
561
- case 'claude-code':
562
- case 'codex':
563
- case 'openhands': {
564
- const envFilePath = join(homedir(), `.fcm-${canonical}-env`)
565
- try {
566
- if (existsSync(envFilePath)) {
567
- unlinkSync(envFilePath)
568
- }
569
- } catch { /* best effort */ }
570
- return { success: true }
571
- }
572
- default:
573
- return { success: false, error: `Unknown tool: ${toolMode}` }
574
- }
575
- } catch (err) {
576
- return { success: false, error: err.message }
577
- }
578
- }
579
-
580
- /**
581
- * 📖 Returns the list of tools that support proxy sync.
582
- * 📖 Enriched with display metadata from tool-metadata.js.
583
- *
584
- * @returns {Array<{ key: string, label: string, emoji: string }>}
585
- */
586
- export function getProxySyncableTools() {
587
- return PROXY_SYNCABLE_TOOLS.map(key => {
588
- const meta = getToolMeta(key)
589
- return { key, label: meta.label, emoji: meta.emoji }
590
- })
591
- }
@@ -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
- }