free-coding-models 0.2.17 → 0.3.1

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