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/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
- }
@@ -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
- }