free-coding-models 0.2.0 → 0.2.2

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,459 @@
1
+ /**
2
+ * @file src/endpoint-installer.js
3
+ * @description Install and refresh FCM-managed provider catalogs inside external tool configs.
4
+ *
5
+ * @details
6
+ * 📖 This module powers the `Y` hotkey flow in the TUI.
7
+ * It lets users pick one configured provider, choose a target tool, then install either:
8
+ * - the full provider catalog (`all` models), or
9
+ * - a curated subset of specific models (`selected`)
10
+ *
11
+ * 📖 The implementation is intentionally conservative:
12
+ * - it writes managed provider entries under an `fcm-*` namespace to avoid clobbering user-defined providers
13
+ * - it merges into existing config files instead of replacing them
14
+ * - it records successful installs in `~/.free-coding-models.json` so catalogs can be refreshed automatically later
15
+ *
16
+ * 📖 Tool-specific notes:
17
+ * - OpenCode CLI and OpenCode Desktop share the same `opencode.json`
18
+ * - Crush gets a managed provider block in `crush.json`
19
+ * - Goose gets a declarative custom provider JSON + a matching secret in `secrets.yaml`
20
+ * - OpenClaw gets a managed `models.providers` entry plus matching allowlist rows
21
+ *
22
+ * @functions
23
+ * → `getConfiguredInstallableProviders` — list configured providers that support direct endpoint installs
24
+ * → `getProviderCatalogModels` — return the current FCM catalog for one provider
25
+ * → `getInstallTargetModes` — stable install target list exposed in the TUI
26
+ * → `installProviderEndpoints` — install one provider catalog into one external tool
27
+ * → `refreshInstalledEndpoints` — replay tracked installs to keep catalogs in sync on future launches
28
+ *
29
+ * @exports
30
+ * getConfiguredInstallableProviders, getProviderCatalogModels, getInstallTargetModes,
31
+ * installProviderEndpoints, refreshInstalledEndpoints
32
+ *
33
+ * @see ../sources.js
34
+ * @see src/config.js
35
+ * @see src/tool-metadata.js
36
+ */
37
+
38
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
39
+ import { homedir } from 'node:os'
40
+ import { dirname, join } from 'node:path'
41
+ import { MODELS, sources } from '../sources.js'
42
+ import { getApiKey, saveConfig } from './config.js'
43
+ import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
44
+ import { getToolMeta } from './tool-metadata.js'
45
+
46
+ const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate', 'zai'])
47
+ const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose']
48
+
49
+ function getDefaultPaths() {
50
+ const home = homedir()
51
+ return {
52
+ opencodeConfigPath: join(home, '.config', 'opencode', 'opencode.json'),
53
+ openclawConfigPath: join(home, '.openclaw', 'openclaw.json'),
54
+ crushConfigPath: join(home, '.config', 'crush', 'crush.json'),
55
+ gooseProvidersDir: join(home, '.config', 'goose', 'custom_providers'),
56
+ gooseSecretsPath: join(home, '.config', 'goose', 'secrets.yaml'),
57
+ }
58
+ }
59
+
60
+ function ensureDirFor(filePath) {
61
+ mkdirSync(dirname(filePath), { recursive: true })
62
+ }
63
+
64
+ function backupIfExists(filePath) {
65
+ if (!existsSync(filePath)) return null
66
+ const backupPath = `${filePath}.backup-${Date.now()}`
67
+ copyFileSync(filePath, backupPath)
68
+ return backupPath
69
+ }
70
+
71
+ function readJson(filePath, fallback = {}) {
72
+ if (!existsSync(filePath)) return fallback
73
+ try {
74
+ return JSON.parse(readFileSync(filePath, 'utf8'))
75
+ } catch {
76
+ return fallback
77
+ }
78
+ }
79
+
80
+ function writeJson(filePath, value, { backup = true } = {}) {
81
+ ensureDirFor(filePath)
82
+ const backupPath = backup ? backupIfExists(filePath) : null
83
+ writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n')
84
+ return backupPath
85
+ }
86
+
87
+ function readSimpleYamlMap(filePath) {
88
+ if (!existsSync(filePath)) return {}
89
+ const out = {}
90
+ const lines = readFileSync(filePath, 'utf8').split(/\r?\n/)
91
+ for (const line of lines) {
92
+ if (!line.trim() || line.trim().startsWith('#')) continue
93
+ const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
94
+ if (!match) continue
95
+ let value = match[2].trim()
96
+ if (
97
+ (value.startsWith('"') && value.endsWith('"')) ||
98
+ (value.startsWith('\'') && value.endsWith('\''))
99
+ ) {
100
+ value = value.slice(1, -1)
101
+ }
102
+ out[match[1]] = value
103
+ }
104
+ return out
105
+ }
106
+
107
+ function writeSimpleYamlMap(filePath, entries) {
108
+ ensureDirFor(filePath)
109
+ const backupPath = backupIfExists(filePath)
110
+ const lines = Object.keys(entries)
111
+ .sort()
112
+ .map((key) => `${key}: ${JSON.stringify(String(entries[key] ?? ''))}`)
113
+ writeFileSync(filePath, lines.join('\n') + '\n')
114
+ return backupPath
115
+ }
116
+
117
+ function canonicalizeToolMode(toolMode) {
118
+ return toolMode === 'opencode-desktop' ? 'opencode' : toolMode
119
+ }
120
+
121
+ function getManagedProviderId(providerKey) {
122
+ return `fcm-${providerKey}`
123
+ }
124
+
125
+ function getProviderLabel(providerKey) {
126
+ return PROVIDER_METADATA[providerKey]?.label || sources[providerKey]?.name || providerKey
127
+ }
128
+
129
+ function getManagedProviderLabel(providerKey) {
130
+ return `FCM ${getProviderLabel(providerKey)}`
131
+ }
132
+
133
+ function parseContextWindow(ctx) {
134
+ if (typeof ctx !== 'string' || !ctx.trim()) return 128000
135
+ const trimmed = ctx.trim().toLowerCase()
136
+ const multiplier = trimmed.endsWith('m') ? 1_000_000 : trimmed.endsWith('k') ? 1_000 : 1
137
+ const numeric = Number.parseFloat(trimmed.replace(/[mk]$/i, ''))
138
+ if (!Number.isFinite(numeric) || numeric <= 0) return 128000
139
+ return Math.round(numeric * multiplier)
140
+ }
141
+
142
+ function getDefaultMaxTokens(contextWindow) {
143
+ return Math.max(4096, Math.min(contextWindow, 32768))
144
+ }
145
+
146
+ function resolveProviderBaseUrl(providerKey) {
147
+ const providerUrl = sources[providerKey]?.url
148
+ if (!providerUrl) return null
149
+
150
+ if (providerKey === 'cloudflare') {
151
+ const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
152
+ if (!accountId) return null
153
+ return providerUrl.replace('{account_id}', accountId).replace(/\/chat\/completions$/i, '')
154
+ }
155
+
156
+ return providerUrl
157
+ .replace(/\/chat\/completions$/i, '')
158
+ .replace(/\/responses$/i, '')
159
+ .replace(/\/predictions$/i, '')
160
+ }
161
+
162
+ function resolveGooseBaseUrl(providerKey) {
163
+ const providerUrl = sources[providerKey]?.url
164
+ if (!providerUrl) return null
165
+ if (providerKey === 'cloudflare') {
166
+ const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
167
+ if (!accountId) return null
168
+ return providerUrl.replace('{account_id}', accountId)
169
+ }
170
+ return providerUrl
171
+ }
172
+
173
+ function getDirectInstallSupport(providerKey) {
174
+ if (!sources[providerKey]) {
175
+ return { supported: false, reason: 'Unknown provider' }
176
+ }
177
+ if (DIRECT_INSTALL_UNSUPPORTED_PROVIDERS.has(providerKey)) {
178
+ return { supported: false, reason: 'This provider needs a non-standard proxy/runtime bridge' }
179
+ }
180
+ if (providerKey === 'cloudflare' && !(process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()) {
181
+ return { supported: false, reason: 'CLOUDFLARE_ACCOUNT_ID is required for direct installs' }
182
+ }
183
+ return { supported: true, reason: null }
184
+ }
185
+
186
+ function buildInstallRecord(providerKey, toolMode, scope, modelIds) {
187
+ return {
188
+ providerKey,
189
+ toolMode: canonicalizeToolMode(toolMode),
190
+ scope: scope === 'selected' ? 'selected' : 'all',
191
+ modelIds: scope === 'selected' ? [...new Set(modelIds)] : [],
192
+ lastSyncedAt: new Date().toISOString(),
193
+ }
194
+ }
195
+
196
+ function upsertInstallRecord(config, record) {
197
+ if (!Array.isArray(config.endpointInstalls)) config.endpointInstalls = []
198
+ const next = config.endpointInstalls.filter(
199
+ (entry) => !(entry?.providerKey === record.providerKey && entry?.toolMode === record.toolMode)
200
+ )
201
+ next.push(record)
202
+ config.endpointInstalls = next
203
+ }
204
+
205
+ function buildCatalogModel(modelId, label, tier, sweScore, ctx) {
206
+ return { modelId, label, tier, sweScore, ctx }
207
+ }
208
+
209
+ export function getProviderCatalogModels(providerKey) {
210
+ const seen = new Set()
211
+ return MODELS
212
+ .filter((entry) => entry[5] === providerKey)
213
+ .map(([modelId, label, tier, sweScore, ctx]) => buildCatalogModel(modelId, label, tier, sweScore, ctx))
214
+ .filter((entry) => {
215
+ if (seen.has(entry.modelId)) return false
216
+ seen.add(entry.modelId)
217
+ return true
218
+ })
219
+ }
220
+
221
+ export function getConfiguredInstallableProviders(config) {
222
+ return Object.keys(sources)
223
+ .filter((providerKey) => getApiKey(config, providerKey))
224
+ .map((providerKey) => {
225
+ const support = getDirectInstallSupport(providerKey)
226
+ return {
227
+ providerKey,
228
+ label: getProviderLabel(providerKey),
229
+ modelCount: getProviderCatalogModels(providerKey).length,
230
+ supported: support.supported,
231
+ reason: support.reason,
232
+ }
233
+ })
234
+ .filter((provider) => provider.supported)
235
+ }
236
+
237
+ export function getInstallTargetModes() {
238
+ return [...INSTALL_TARGET_MODES]
239
+ }
240
+
241
+ function requireConfiguredProviderKey(config, providerKey) {
242
+ const apiKey = getApiKey(config, providerKey)
243
+ if (!apiKey) {
244
+ throw new Error(`No configured API key found for ${getProviderLabel(providerKey)}`)
245
+ }
246
+ return apiKey
247
+ }
248
+
249
+ function resolveSelectedModels(providerKey, scope, modelIds) {
250
+ const catalog = getProviderCatalogModels(providerKey)
251
+ if (scope !== 'selected') return catalog
252
+ const selectedSet = new Set(modelIds)
253
+ return catalog.filter((model) => selectedSet.has(model.modelId))
254
+ }
255
+
256
+ function installIntoOpenCode(providerKey, models, apiKey, paths) {
257
+ const filePath = paths.opencodeConfigPath
258
+ const providerId = getManagedProviderId(providerKey)
259
+ const config = readJson(filePath, {})
260
+ if (!config.provider || typeof config.provider !== 'object') config.provider = {}
261
+
262
+ config.provider[providerId] = {
263
+ npm: '@ai-sdk/openai-compatible',
264
+ name: getManagedProviderLabel(providerKey),
265
+ options: {
266
+ baseURL: resolveProviderBaseUrl(providerKey),
267
+ apiKey,
268
+ },
269
+ models: Object.fromEntries(models.map((model) => [model.modelId, { name: model.label }])),
270
+ }
271
+
272
+ const backupPath = writeJson(filePath, config)
273
+ return { path: filePath, backupPath, providerId, modelCount: models.length }
274
+ }
275
+
276
+ function installIntoCrush(providerKey, models, apiKey, paths) {
277
+ const filePath = paths.crushConfigPath
278
+ const providerId = getManagedProviderId(providerKey)
279
+ const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
280
+ if (!config.providers || typeof config.providers !== 'object') config.providers = {}
281
+
282
+ config.providers[providerId] = {
283
+ name: getManagedProviderLabel(providerKey),
284
+ type: 'openai-compat',
285
+ base_url: resolveProviderBaseUrl(providerKey),
286
+ api_key: apiKey,
287
+ models: models.map((model) => ({
288
+ id: model.modelId,
289
+ name: model.label,
290
+ context_window: parseContextWindow(model.ctx),
291
+ default_max_tokens: getDefaultMaxTokens(parseContextWindow(model.ctx)),
292
+ })),
293
+ }
294
+
295
+ const backupPath = writeJson(filePath, config)
296
+ return { path: filePath, backupPath, providerId, modelCount: models.length }
297
+ }
298
+
299
+ function installIntoGoose(providerKey, models, apiKey, paths) {
300
+ const providerId = getManagedProviderId(providerKey)
301
+ const providerFilePath = join(paths.gooseProvidersDir, `${providerId}.json`)
302
+ const secretEnvName = `FCM_${providerKey.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
303
+
304
+ const providerConfig = {
305
+ name: providerId,
306
+ engine: 'openai',
307
+ display_name: getManagedProviderLabel(providerKey),
308
+ description: `Managed by free-coding-models for ${getProviderLabel(providerKey)}`,
309
+ api_key_env: secretEnvName,
310
+ base_url: resolveGooseBaseUrl(providerKey),
311
+ models: models.map((model) => ({
312
+ name: model.modelId,
313
+ context_limit: parseContextWindow(model.ctx),
314
+ })),
315
+ supports_streaming: true,
316
+ requires_auth: true,
317
+ }
318
+
319
+ const providerBackupPath = writeJson(providerFilePath, providerConfig)
320
+
321
+ const secrets = readSimpleYamlMap(paths.gooseSecretsPath)
322
+ secrets[secretEnvName] = apiKey
323
+ const secretsBackupPath = writeSimpleYamlMap(paths.gooseSecretsPath, secrets)
324
+
325
+ return {
326
+ path: providerFilePath,
327
+ backupPath: providerBackupPath,
328
+ providerId,
329
+ modelCount: models.length,
330
+ extraPath: paths.gooseSecretsPath,
331
+ extraBackupPath: secretsBackupPath,
332
+ }
333
+ }
334
+
335
+ function installIntoOpenClaw(providerKey, models, apiKey, paths) {
336
+ const filePath = paths.openclawConfigPath
337
+ const providerId = getManagedProviderId(providerKey)
338
+ const config = readJson(filePath, {})
339
+
340
+ if (!config.models || typeof config.models !== 'object') config.models = {}
341
+ if (config.models.mode !== 'replace') config.models.mode = 'merge'
342
+ if (!config.models.providers || typeof config.models.providers !== 'object') config.models.providers = {}
343
+ if (!config.agents || typeof config.agents !== 'object') config.agents = {}
344
+ if (!config.agents.defaults || typeof config.agents.defaults !== 'object') config.agents.defaults = {}
345
+ if (!config.agents.defaults.models || typeof config.agents.defaults.models !== 'object') config.agents.defaults.models = {}
346
+
347
+ config.models.providers[providerId] = {
348
+ baseUrl: resolveProviderBaseUrl(providerKey),
349
+ apiKey,
350
+ api: 'openai-completions',
351
+ models: models.map((model) => {
352
+ const contextWindow = parseContextWindow(model.ctx)
353
+ return {
354
+ id: model.modelId,
355
+ name: model.label,
356
+ api: 'openai-completions',
357
+ reasoning: false,
358
+ input: ['text'],
359
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
360
+ contextWindow,
361
+ maxTokens: getDefaultMaxTokens(contextWindow),
362
+ }
363
+ }),
364
+ }
365
+
366
+ for (const modelRef of Object.keys(config.agents.defaults.models)) {
367
+ if (modelRef.startsWith(`${providerId}/`)) delete config.agents.defaults.models[modelRef]
368
+ }
369
+ for (const model of models) {
370
+ config.agents.defaults.models[`${providerId}/${model.modelId}`] = {}
371
+ }
372
+
373
+ const backupPath = writeJson(filePath, config)
374
+ return { path: filePath, backupPath, providerId, modelCount: models.length }
375
+ }
376
+
377
+ export function installProviderEndpoints(config, providerKey, toolMode, options = {}) {
378
+ const canonicalToolMode = canonicalizeToolMode(toolMode)
379
+ const support = getDirectInstallSupport(providerKey)
380
+ if (!support.supported) {
381
+ throw new Error(support.reason || 'Direct install is not supported for this provider')
382
+ }
383
+
384
+ const apiKey = requireConfiguredProviderKey(config, providerKey)
385
+ const scope = options.scope === 'selected' ? 'selected' : 'all'
386
+ const models = resolveSelectedModels(providerKey, scope, options.modelIds || [])
387
+ if (models.length === 0) {
388
+ throw new Error(`No models available to install for ${getProviderLabel(providerKey)}`)
389
+ }
390
+
391
+ const paths = { ...getDefaultPaths(), ...(options.paths || {}) }
392
+ let installResult
393
+ if (canonicalToolMode === 'opencode') {
394
+ installResult = installIntoOpenCode(providerKey, models, apiKey, paths)
395
+ } else if (canonicalToolMode === 'openclaw') {
396
+ installResult = installIntoOpenClaw(providerKey, models, apiKey, paths)
397
+ } else if (canonicalToolMode === 'crush') {
398
+ installResult = installIntoCrush(providerKey, models, apiKey, paths)
399
+ } else if (canonicalToolMode === 'goose') {
400
+ installResult = installIntoGoose(providerKey, models, apiKey, paths)
401
+ } else {
402
+ throw new Error(`Unsupported install target: ${toolMode}`)
403
+ }
404
+
405
+ if (options.track !== false) {
406
+ upsertInstallRecord(config, buildInstallRecord(providerKey, canonicalToolMode, scope, models.map((model) => model.modelId)))
407
+ saveConfig(config)
408
+ }
409
+
410
+ return {
411
+ ...installResult,
412
+ toolMode: canonicalToolMode,
413
+ toolLabel: getToolMeta(toolMode).label,
414
+ providerKey,
415
+ providerLabel: getProviderLabel(providerKey),
416
+ scope,
417
+ autoRefreshEnabled: true,
418
+ models,
419
+ }
420
+ }
421
+
422
+ export function refreshInstalledEndpoints(config, options = {}) {
423
+ if (!Array.isArray(config?.endpointInstalls) || config.endpointInstalls.length === 0) {
424
+ return { refreshed: 0, failed: 0, errors: [] }
425
+ }
426
+
427
+ let refreshed = 0
428
+ let failed = 0
429
+ const errors = []
430
+
431
+ for (const record of config.endpointInstalls) {
432
+ try {
433
+ installProviderEndpoints(config, record.providerKey, record.toolMode, {
434
+ scope: record.scope,
435
+ modelIds: record.modelIds,
436
+ track: false,
437
+ paths: options.paths,
438
+ })
439
+ refreshed += 1
440
+ } catch (error) {
441
+ failed += 1
442
+ errors.push({
443
+ providerKey: record.providerKey,
444
+ toolMode: record.toolMode,
445
+ message: error instanceof Error ? error.message : String(error),
446
+ })
447
+ }
448
+ }
449
+
450
+ if (refreshed > 0) {
451
+ config.endpointInstalls = config.endpointInstalls.map((record) => ({
452
+ ...record,
453
+ lastSyncedAt: new Date().toISOString(),
454
+ }))
455
+ saveConfig(config)
456
+ }
457
+
458
+ return { refreshed, failed, errors }
459
+ }