free-coding-models 0.2.15 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/README.md +112 -39
- package/bin/fcm-proxy-daemon.js +239 -0
- package/bin/free-coding-models.js +105 -23
- package/package.json +3 -2
- package/src/account-manager.js +34 -0
- package/src/anthropic-translator.js +370 -0
- package/src/config.js +24 -1
- package/src/daemon-manager.js +527 -0
- package/src/endpoint-installer.js +187 -2
- package/src/key-handler.js +355 -150
- package/src/opencode.js +30 -32
- package/src/overlays.js +365 -225
- package/src/proxy-server.js +488 -6
- package/src/proxy-sync.js +552 -0
- package/src/proxy-topology.js +80 -0
- package/src/render-table.js +24 -15
- package/src/tool-launchers.js +138 -18
- package/src/tool-metadata.js +14 -14
|
@@ -13,11 +13,21 @@
|
|
|
13
13
|
* - it merges into existing config files instead of replacing them
|
|
14
14
|
* - it records successful installs in `~/.free-coding-models.json` so catalogs can be refreshed automatically later
|
|
15
15
|
*
|
|
16
|
+
* 📖 Connection modes:
|
|
17
|
+
* - `direct` — connect the tool straight to the provider API endpoint (no proxy)
|
|
18
|
+
* - `proxy` — route through the local FCM proxy (key rotation + usage tracking)
|
|
19
|
+
*
|
|
16
20
|
* 📖 Tool-specific notes:
|
|
17
21
|
* - OpenCode CLI and OpenCode Desktop share the same `opencode.json`
|
|
18
22
|
* - Crush gets a managed provider block in `crush.json`
|
|
19
23
|
* - Goose gets a declarative custom provider JSON + a matching secret in `secrets.yaml`
|
|
20
24
|
* - OpenClaw gets a managed `models.providers` entry plus matching allowlist rows
|
|
25
|
+
* - Pi gets models.json + settings.json under ~/.pi/agent/
|
|
26
|
+
* - Aider gets ~/.aider.conf.yml with OpenAI-compatible config
|
|
27
|
+
* - Amp gets ~/.config/amp/settings.json
|
|
28
|
+
* - Gemini gets ~/.gemini/settings.json
|
|
29
|
+
* - Qwen gets ~/.qwen/settings.json with modelProviders
|
|
30
|
+
* - Claude Code, Codex, OpenHands get a sourceable env file (~/.fcm-{tool}-env)
|
|
21
31
|
*
|
|
22
32
|
* @functions
|
|
23
33
|
* → `getConfiguredInstallableProviders` — list configured providers that support direct endpoint installs
|
|
@@ -39,12 +49,19 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
|
|
|
39
49
|
import { homedir } from 'node:os'
|
|
40
50
|
import { dirname, join } from 'node:path'
|
|
41
51
|
import { MODELS, sources } from '../sources.js'
|
|
42
|
-
import { getApiKey, saveConfig } from './config.js'
|
|
52
|
+
import { getApiKey, saveConfig, getProxySettings, loadConfig } from './config.js'
|
|
43
53
|
import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
|
|
44
54
|
import { getToolMeta } from './tool-metadata.js'
|
|
45
55
|
|
|
46
56
|
const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate', 'zai'])
|
|
47
|
-
|
|
57
|
+
// 📖 All supported install targets — matches TOOL_MODE_ORDER in tool-metadata.js
|
|
58
|
+
const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'claude-code', 'codex', 'gemini', 'qwen', 'openhands', 'amp']
|
|
59
|
+
|
|
60
|
+
// 📖 Connection modes: direct (pure provider) vs FCM proxy (rotates keys)
|
|
61
|
+
export const CONNECTION_MODES = [
|
|
62
|
+
{ key: 'direct', label: 'Direct Provider', hint: 'Connect the tool straight to the provider API — no proxy involved.' },
|
|
63
|
+
{ key: 'proxy', label: 'FCM Proxy V2', hint: 'Route through FCM Proxy V2 with key rotation and usage tracking.' },
|
|
64
|
+
]
|
|
48
65
|
|
|
49
66
|
function getDefaultPaths() {
|
|
50
67
|
const home = homedir()
|
|
@@ -54,6 +71,12 @@ function getDefaultPaths() {
|
|
|
54
71
|
crushConfigPath: join(home, '.config', 'crush', 'crush.json'),
|
|
55
72
|
gooseProvidersDir: join(home, '.config', 'goose', 'custom_providers'),
|
|
56
73
|
gooseSecretsPath: join(home, '.config', 'goose', 'secrets.yaml'),
|
|
74
|
+
piModelsPath: join(home, '.pi', 'agent', 'models.json'),
|
|
75
|
+
piSettingsPath: join(home, '.pi', 'agent', 'settings.json'),
|
|
76
|
+
aiderConfigPath: join(home, '.aider.conf.yml'),
|
|
77
|
+
ampConfigPath: join(home, '.config', 'amp', 'settings.json'),
|
|
78
|
+
geminiConfigPath: join(home, '.gemini', 'settings.json'),
|
|
79
|
+
qwenConfigPath: join(home, '.qwen', 'settings.json'),
|
|
57
80
|
}
|
|
58
81
|
}
|
|
59
82
|
|
|
@@ -374,8 +397,156 @@ function installIntoOpenClaw(providerKey, models, apiKey, paths) {
|
|
|
374
397
|
return { path: filePath, backupPath, providerId, modelCount: models.length }
|
|
375
398
|
}
|
|
376
399
|
|
|
400
|
+
// 📖 installIntoPi writes models.json + settings.json under ~/.pi/agent/
|
|
401
|
+
function installIntoPi(providerKey, models, apiKey, paths) {
|
|
402
|
+
const providerId = getManagedProviderId(providerKey)
|
|
403
|
+
const baseUrl = resolveProviderBaseUrl(providerKey)
|
|
404
|
+
|
|
405
|
+
// 📖 Write models.json with provider config
|
|
406
|
+
const modelsConfig = readJson(paths.piModelsPath, { providers: {} })
|
|
407
|
+
if (!modelsConfig.providers || typeof modelsConfig.providers !== 'object') modelsConfig.providers = {}
|
|
408
|
+
modelsConfig.providers[providerId] = {
|
|
409
|
+
baseUrl,
|
|
410
|
+
api: 'openai-completions',
|
|
411
|
+
apiKey,
|
|
412
|
+
models: models.map((model) => ({ id: model.modelId, name: model.label })),
|
|
413
|
+
}
|
|
414
|
+
const modelsBackupPath = writeJson(paths.piModelsPath, modelsConfig)
|
|
415
|
+
|
|
416
|
+
// 📖 Write settings.json to set default provider
|
|
417
|
+
const settingsConfig = readJson(paths.piSettingsPath, {})
|
|
418
|
+
settingsConfig.defaultProvider = providerId
|
|
419
|
+
settingsConfig.defaultModel = models[0]?.modelId ?? ''
|
|
420
|
+
writeJson(paths.piSettingsPath, settingsConfig, { backup: true })
|
|
421
|
+
|
|
422
|
+
return { path: paths.piModelsPath, backupPath: modelsBackupPath, providerId, modelCount: models.length }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 📖 installIntoAider writes ~/.aider.conf.yml with OpenAI-compatible config
|
|
426
|
+
function installIntoAider(providerKey, models, apiKey, paths) {
|
|
427
|
+
const providerId = getManagedProviderId(providerKey)
|
|
428
|
+
const baseUrl = resolveProviderBaseUrl(providerKey)
|
|
429
|
+
const backupPath = backupIfExists(paths.aiderConfigPath)
|
|
430
|
+
// 📖 Aider YAML config — one model at a time, uses first selected model
|
|
431
|
+
const primaryModel = models[0]
|
|
432
|
+
const lines = [
|
|
433
|
+
'# 📖 Managed by free-coding-models',
|
|
434
|
+
`openai-api-base: ${baseUrl}`,
|
|
435
|
+
`openai-api-key: ${apiKey}`,
|
|
436
|
+
`model: openai/${primaryModel.modelId}`,
|
|
437
|
+
'',
|
|
438
|
+
]
|
|
439
|
+
ensureDirFor(paths.aiderConfigPath)
|
|
440
|
+
writeFileSync(paths.aiderConfigPath, lines.join('\n'))
|
|
441
|
+
return { path: paths.aiderConfigPath, backupPath, providerId, modelCount: models.length }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 📖 installIntoAmp writes ~/.config/amp/settings.json with model+URL
|
|
445
|
+
function installIntoAmp(providerKey, models, apiKey, paths) {
|
|
446
|
+
const providerId = getManagedProviderId(providerKey)
|
|
447
|
+
const baseUrl = resolveProviderBaseUrl(providerKey)
|
|
448
|
+
const config = readJson(paths.ampConfigPath, {})
|
|
449
|
+
config['amp.url'] = baseUrl
|
|
450
|
+
config['amp.model'] = models[0]?.modelId ?? ''
|
|
451
|
+
const backupPath = writeJson(paths.ampConfigPath, config)
|
|
452
|
+
return { path: paths.ampConfigPath, backupPath, providerId, modelCount: models.length }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 📖 installIntoGemini writes ~/.gemini/settings.json with model ID
|
|
456
|
+
function installIntoGemini(providerKey, models, apiKey, paths) {
|
|
457
|
+
const providerId = getManagedProviderId(providerKey)
|
|
458
|
+
const config = readJson(paths.geminiConfigPath, {})
|
|
459
|
+
config.model = models[0]?.modelId ?? ''
|
|
460
|
+
const backupPath = writeJson(paths.geminiConfigPath, config)
|
|
461
|
+
return { path: paths.geminiConfigPath, backupPath, providerId, modelCount: models.length }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 📖 installIntoQwen writes ~/.qwen/settings.json with modelProviders config
|
|
465
|
+
function installIntoQwen(providerKey, models, apiKey, paths) {
|
|
466
|
+
const providerId = getManagedProviderId(providerKey)
|
|
467
|
+
const baseUrl = resolveProviderBaseUrl(providerKey)
|
|
468
|
+
const config = readJson(paths.qwenConfigPath, {})
|
|
469
|
+
if (!config.modelProviders || typeof config.modelProviders !== 'object') config.modelProviders = {}
|
|
470
|
+
if (!Array.isArray(config.modelProviders.openai)) config.modelProviders.openai = []
|
|
471
|
+
|
|
472
|
+
// 📖 Remove existing FCM-managed entries, then prepend all selected models
|
|
473
|
+
const filtered = config.modelProviders.openai.filter(
|
|
474
|
+
(entry) => !models.some((m) => m.modelId === entry?.id)
|
|
475
|
+
)
|
|
476
|
+
const newEntries = models.map((model) => ({
|
|
477
|
+
id: model.modelId,
|
|
478
|
+
name: model.label,
|
|
479
|
+
envKey: ENV_VAR_NAMES[providerKey] || 'OPENAI_API_KEY',
|
|
480
|
+
baseUrl,
|
|
481
|
+
}))
|
|
482
|
+
config.modelProviders.openai = [...newEntries, ...filtered]
|
|
483
|
+
config.model = models[0]?.modelId ?? ''
|
|
484
|
+
const backupPath = writeJson(paths.qwenConfigPath, config)
|
|
485
|
+
return { path: paths.qwenConfigPath, backupPath, providerId, modelCount: models.length }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 📖 installIntoEnvBasedTool handles tools that rely on env vars only (claude-code, codex, openhands).
|
|
489
|
+
// 📖 We write a small .env-style helper file so users can source it before launching.
|
|
490
|
+
// 📖 When connectionMode is 'proxy', writes env vars pointing to the daemon's stable port/token.
|
|
491
|
+
function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode, paths, connectionMode = 'direct') {
|
|
492
|
+
const providerId = getManagedProviderId(providerKey)
|
|
493
|
+
const home = homedir()
|
|
494
|
+
const envFileName = `.fcm-${toolMode}-env`
|
|
495
|
+
const envFilePath = join(home, envFileName)
|
|
496
|
+
const primaryModel = models[0]
|
|
497
|
+
|
|
498
|
+
// 📖 Resolve effective API key, base URL, and model ID based on connection mode
|
|
499
|
+
let effectiveApiKey = apiKey
|
|
500
|
+
let effectiveBaseUrl = resolveProviderBaseUrl(providerKey)
|
|
501
|
+
let effectiveModelId = primaryModel.modelId
|
|
502
|
+
|
|
503
|
+
if (connectionMode === 'proxy') {
|
|
504
|
+
// 📖 Read stable proxy settings from config for daemon-compatible env files
|
|
505
|
+
try {
|
|
506
|
+
const cfg = loadConfig()
|
|
507
|
+
const proxySettings = getProxySettings(cfg)
|
|
508
|
+
effectiveApiKey = proxySettings.stableToken || apiKey
|
|
509
|
+
const port = proxySettings.preferredPort || 18045
|
|
510
|
+
effectiveBaseUrl = `http://127.0.0.1:${port}/v1`
|
|
511
|
+
} catch { /* fallback to direct values */ }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const envLines = [
|
|
515
|
+
'# 📖 Managed by free-coding-models — source this file before launching the tool',
|
|
516
|
+
`# 📖 Provider: ${getProviderLabel(providerKey)} (${models.length} models)`,
|
|
517
|
+
`# 📖 Connection: ${connectionMode === 'proxy' ? 'FCM Proxy V2 (background service)' : 'Direct provider'}`,
|
|
518
|
+
`export OPENAI_API_KEY="${effectiveApiKey}"`,
|
|
519
|
+
`export OPENAI_BASE_URL="${effectiveBaseUrl}"`,
|
|
520
|
+
`export OPENAI_MODEL="${effectiveModelId}"`,
|
|
521
|
+
`export LLM_API_KEY="${effectiveApiKey}"`,
|
|
522
|
+
`export LLM_BASE_URL="${effectiveBaseUrl}"`,
|
|
523
|
+
`export LLM_MODEL="openai/${effectiveModelId}"`,
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
// 📖 Claude Code: Anthropic-specific env vars pointing to proxy /v1/messages endpoint
|
|
527
|
+
if (toolMode === 'claude-code') {
|
|
528
|
+
if (connectionMode === 'proxy') {
|
|
529
|
+
// 📖 Point to proxy base (not /v1) — Claude Code adds /v1/messages itself
|
|
530
|
+
const proxyBase = effectiveBaseUrl.replace(/\/v1$/, '')
|
|
531
|
+
envLines.push(`export ANTHROPIC_API_KEY="${effectiveApiKey}"`)
|
|
532
|
+
envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
|
|
533
|
+
envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
|
|
534
|
+
} else {
|
|
535
|
+
envLines.push(`export ANTHROPIC_AUTH_TOKEN="${effectiveApiKey}"`)
|
|
536
|
+
envLines.push(`export ANTHROPIC_BASE_URL="${effectiveBaseUrl}"`)
|
|
537
|
+
envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
ensureDirFor(envFilePath)
|
|
542
|
+
const backupPath = backupIfExists(envFilePath)
|
|
543
|
+
writeFileSync(envFilePath, envLines.join('\n') + '\n')
|
|
544
|
+
return { path: envFilePath, backupPath, providerId, modelCount: models.length }
|
|
545
|
+
}
|
|
546
|
+
|
|
377
547
|
export function installProviderEndpoints(config, providerKey, toolMode, options = {}) {
|
|
378
548
|
const canonicalToolMode = canonicalizeToolMode(toolMode)
|
|
549
|
+
const connectionMode = options.connectionMode || 'direct'
|
|
379
550
|
const support = getDirectInstallSupport(providerKey)
|
|
380
551
|
if (!support.supported) {
|
|
381
552
|
throw new Error(support.reason || 'Direct install is not supported for this provider')
|
|
@@ -389,6 +560,7 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
|
|
|
389
560
|
}
|
|
390
561
|
|
|
391
562
|
const paths = { ...getDefaultPaths(), ...(options.paths || {}) }
|
|
563
|
+
// 📖 Dispatch to the right installer based on canonical tool mode
|
|
392
564
|
let installResult
|
|
393
565
|
if (canonicalToolMode === 'opencode') {
|
|
394
566
|
installResult = installIntoOpenCode(providerKey, models, apiKey, paths)
|
|
@@ -398,6 +570,18 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
|
|
|
398
570
|
installResult = installIntoCrush(providerKey, models, apiKey, paths)
|
|
399
571
|
} else if (canonicalToolMode === 'goose') {
|
|
400
572
|
installResult = installIntoGoose(providerKey, models, apiKey, paths)
|
|
573
|
+
} else if (canonicalToolMode === 'pi') {
|
|
574
|
+
installResult = installIntoPi(providerKey, models, apiKey, paths)
|
|
575
|
+
} else if (canonicalToolMode === 'aider') {
|
|
576
|
+
installResult = installIntoAider(providerKey, models, apiKey, paths)
|
|
577
|
+
} else if (canonicalToolMode === 'amp') {
|
|
578
|
+
installResult = installIntoAmp(providerKey, models, apiKey, paths)
|
|
579
|
+
} else if (canonicalToolMode === 'gemini') {
|
|
580
|
+
installResult = installIntoGemini(providerKey, models, apiKey, paths)
|
|
581
|
+
} else if (canonicalToolMode === 'qwen') {
|
|
582
|
+
installResult = installIntoQwen(providerKey, models, apiKey, paths)
|
|
583
|
+
} else if (canonicalToolMode === 'claude-code' || canonicalToolMode === 'codex' || canonicalToolMode === 'openhands') {
|
|
584
|
+
installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths, connectionMode)
|
|
401
585
|
} else {
|
|
402
586
|
throw new Error(`Unsupported install target: ${toolMode}`)
|
|
403
587
|
}
|
|
@@ -414,6 +598,7 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
|
|
|
414
598
|
providerKey,
|
|
415
599
|
providerLabel: getProviderLabel(providerKey),
|
|
416
600
|
scope,
|
|
601
|
+
connectionMode,
|
|
417
602
|
autoRefreshEnabled: true,
|
|
418
603
|
models,
|
|
419
604
|
}
|