free-coding-models 0.5.37 → 0.5.38

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,15 @@
1
+ # Changelog v0.5.38 - 2026-06-27
2
+
3
+ ### Added
4
+ - feat(zcode): add full ZCode install-target support across all surfaces (PR #128)
5
+
6
+ ### Fixed
7
+ - fix: regenerate pnpm-lock.yaml to unblock Docker CI (PR #123)
8
+
9
+ ### Changed
10
+ - ci: pnpm-lock.yaml sync for @tanstack/react-virtual (PR #123)
11
+
12
+ ### Closed (unresolved conflicts)
13
+ - deps(deps-dev): bump vite-plus from 0.1.24 to 0.2.1 (PR #127) - Closed due to unresolvable pnpm-lock.yaml conflicts
14
+ - deps(deps): bump kandown from 0.8.0 to 0.13.1 (PR #126) - Closed due to unresolvable pnpm-lock.yaml conflicts
15
+ - ci(deps): bump actions/checkout from 4 to 7 (PR #125) - Skipped due to failing audit check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.37",
3
+ "version": "0.5.38",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -54,7 +54,7 @@ import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
54
54
  const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate'])
55
55
  // 📖 Install Endpoints only lists tools whose persisted config shape is actually supported here.
56
56
  // 📖 Launch-only tools stay out: the Web dashboard configures endpoints, it never starts CLIs.
57
- const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router']
57
+ const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router', 'zcode']
58
58
 
59
59
  function getDefaultPaths() {
60
60
  const home = homedir()
@@ -70,6 +70,8 @@ function getDefaultPaths() {
70
70
  ampConfigPath: join(home, '.config', 'amp', 'settings.json'),
71
71
  qwenConfigPath: join(home, '.qwen', 'settings.json'),
72
72
  forgeCodeConfigPath: join(home, '.forge', '.forge.toml'),
73
+ zcodeConfigPath: join(home, '.zcode', 'v2', 'config.json'),
74
+ zcodeModelCachePath: join(home, '.zcode', 'v2', 'bots-model-cache.v2.json'),
73
75
  }
74
76
  }
75
77
 
@@ -625,6 +627,183 @@ function installIntoFcmRouter(providerKey, models, apiKey) {
625
627
  return { path: `FCM Router (${baseUrl})`, backupPath: null, providerId: providerKey, modelCount: models.length }
626
628
  }
627
629
 
630
+ // 📖 installIntoZCode: Writes provider + models into ZCode's config.json and updates
631
+ // 📖 bots-model-cache.v2.json so the models appear immediately in ZCode's model picker.
632
+ // 📖 Uses deterministic provider ID (fcm-{providerKey}) so re-running replaces/merges
633
+ // 📖 without duplicating the provider entry.
634
+ // 📖 When scope === 'selected' — merges models with existing ones.
635
+ // 📖 When scope === 'all' — replaces all models (full sync).
636
+ function installIntoZCode(providerKey, models, apiKey, paths, scope) {
637
+ const configPath = paths.zcodeConfigPath
638
+ const cachePath = paths.zcodeModelCachePath
639
+ const providerId = `fcm-${providerKey}`
640
+ const baseUrl = resolveProviderBaseUrl(providerKey)
641
+ const providerLabel = getManagedProviderLabel(providerKey)
642
+
643
+ if (!baseUrl) {
644
+ throw new Error(`Cannot resolve base URL for ${getProviderLabel(providerKey)}`)
645
+ }
646
+
647
+ function buildModelEntry(model) {
648
+ const ctx = parseContextWindow(model.ctx)
649
+ const entry = {
650
+ id: model.modelId,
651
+ name: model.label || model.modelId,
652
+ kinds: ['openai-compatible'],
653
+ defaultKind: 'openai-compatible',
654
+ modalities: { input: ['text'], output: ['text'] },
655
+ contextWindow: ctx,
656
+ }
657
+ if (ctx > 8192) {
658
+ entry.maxOutputTokens = getDefaultMaxTokens(ctx)
659
+ }
660
+ return entry
661
+ }
662
+
663
+ function buildConfigModelEntry(model) {
664
+ const ctx = parseContextWindow(model.ctx)
665
+ const entry = {
666
+ limit: { context: ctx },
667
+ modalities: { input: ['text'], output: ['text'] },
668
+ }
669
+ if (ctx > 8192) {
670
+ entry.limit.output = getDefaultMaxTokens(ctx)
671
+ }
672
+ return entry
673
+ }
674
+
675
+ const newModelIds = new Set(models.map((m) => m.modelId))
676
+ let configModified = false
677
+ let cacheModified = false
678
+
679
+ // ── 1. Write to config.json ───────────────────────────────────────────────
680
+ const config = readJson(configPath, { $schema: 'https://opencode.ai/config.json' })
681
+ if (!config.provider || typeof config.provider !== 'object') config.provider = {}
682
+
683
+ const existingProvider = config.provider[providerId]
684
+
685
+ if (scope === 'selected' && existingProvider) {
686
+ // 📖 Merge mode: add/update selected models, keep existing ones
687
+ for (const model of models) {
688
+ existingProvider.models[model.modelId] = buildConfigModelEntry(model)
689
+ }
690
+ configModified = true
691
+ } else if (scope === 'selected' && !existingProvider) {
692
+ // 📖 No existing provider, but scope is selected — create with only selected models
693
+ config.provider[providerId] = {
694
+ name: providerLabel,
695
+ kind: 'openai-compatible',
696
+ options: { apiKey, baseURL: baseUrl, apiKeyRequired: true },
697
+ enabled: true,
698
+ source: 'custom',
699
+ models: Object.fromEntries(models.map((m) => [m.modelId, buildConfigModelEntry(m)])),
700
+ }
701
+ configModified = true
702
+ } else {
703
+ // 📖 scope === 'all' — replace all models (full sync), skip if identical
704
+ const existingModelIds = existingProvider ? Object.keys(existingProvider.models || {}) : []
705
+ const modelsUnchanged = existingProvider
706
+ && existingModelIds.length === models.length
707
+ && models.every((m) => existingModelIds.includes(m.modelId))
708
+
709
+ if (!modelsUnchanged) {
710
+ config.provider[providerId] = {
711
+ name: providerLabel,
712
+ kind: 'openai-compatible',
713
+ options: { apiKey, baseURL: baseUrl, apiKeyRequired: true },
714
+ enabled: true,
715
+ source: 'custom',
716
+ models: Object.fromEntries(models.map((m) => [m.modelId, buildConfigModelEntry(m)])),
717
+ }
718
+ configModified = true
719
+ }
720
+ }
721
+
722
+ const configBackupPath = configModified ? writeJson(configPath, config) : null
723
+
724
+ // ── 2. Write to bots-model-cache.v2.json ──────────────────────────────────
725
+ const cache = readJson(cachePath, { version: 2, updatedAt: Date.now(), providers: [] })
726
+ if (!Array.isArray(cache.providers)) cache.providers = []
727
+
728
+ const existingCacheIdx = cache.providers.findIndex((p) => p?.id === providerId)
729
+
730
+ if (scope === 'selected') {
731
+ // 📖 Merge mode: add/update selected models in cache, keep existing ones
732
+ if (existingCacheIdx >= 0) {
733
+ const existingModels = cache.providers[existingCacheIdx].models || []
734
+ for (const model of models) {
735
+ const modelIdx = existingModels.findIndex((m) => m.id === model.modelId)
736
+ if (modelIdx >= 0) {
737
+ existingModels[modelIdx] = buildModelEntry(model)
738
+ } else {
739
+ existingModels.push(buildModelEntry(model))
740
+ }
741
+ }
742
+ cache.providers[existingCacheIdx].updatedAt = Date.now()
743
+ cacheModified = true
744
+ } else {
745
+ cache.providers.push({
746
+ id: providerId,
747
+ name: providerLabel,
748
+ enabled: true,
749
+ endpoints: { baseURL: baseUrl, paths: { 'openai-compatible': '/chat/completions' } },
750
+ apiFormat: 'openai-chat-completions',
751
+ source: 'custom',
752
+ apiKeyRequired: true,
753
+ apiKey: '__zcode_cached_api_key_present__',
754
+ defaultKind: 'openai-compatible',
755
+ models: models.map(buildModelEntry),
756
+ createdAt: Date.now(),
757
+ updatedAt: Date.now(),
758
+ })
759
+ cacheModified = true
760
+ }
761
+ } else {
762
+ // 📖 scope === 'all' — replace all models, skip if identical
763
+ const cachedModelIds = existingCacheIdx >= 0
764
+ ? (cache.providers[existingCacheIdx].models || []).map((m) => m.id)
765
+ : []
766
+ const cacheUnchanged = existingCacheIdx >= 0
767
+ && cachedModelIds.length === models.length
768
+ && models.every((m) => cachedModelIds.includes(m.modelId))
769
+
770
+ if (!cacheUnchanged) {
771
+ if (existingCacheIdx >= 0) {
772
+ cache.providers[existingCacheIdx].models = models.map(buildModelEntry)
773
+ cache.providers[existingCacheIdx].updatedAt = Date.now()
774
+ } else {
775
+ cache.providers.push({
776
+ id: providerId,
777
+ name: providerLabel,
778
+ enabled: true,
779
+ endpoints: { baseURL: baseUrl, paths: { 'openai-compatible': '/chat/completions' } },
780
+ apiFormat: 'openai-chat-completions',
781
+ source: 'custom',
782
+ apiKeyRequired: true,
783
+ apiKey: '__zcode_cached_api_key_present__',
784
+ defaultKind: 'openai-compatible',
785
+ models: models.map(buildModelEntry),
786
+ createdAt: Date.now(),
787
+ updatedAt: Date.now(),
788
+ })
789
+ }
790
+ cache.updatedAt = Date.now()
791
+ cacheModified = true
792
+ }
793
+ }
794
+
795
+ const cacheBackupPath = cacheModified ? writeJson(cachePath, cache) : null
796
+
797
+ return {
798
+ path: configPath,
799
+ backupPath: configBackupPath,
800
+ providerId,
801
+ modelCount: models.length,
802
+ extraPath: cachePath,
803
+ extraBackupPath: cacheBackupPath,
804
+ }
805
+ }
806
+
628
807
  export function installProviderEndpoints(config, providerKey, toolMode, options = {}) {
629
808
  const canonicalToolMode = canonicalizeToolMode(toolMode)
630
809
  const support = getDirectInstallSupport(providerKey)
@@ -664,6 +843,8 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
664
843
  installResult = installIntoFcmRouter(providerKey, models, apiKey)
665
844
  } else if (canonicalToolMode === 'forgecode') {
666
845
  installResult = installIntoForgeCode(providerKey, models, apiKey, paths)
846
+ } else if (canonicalToolMode === 'zcode') {
847
+ installResult = installIntoZCode(providerKey, models, apiKey, paths, scope)
667
848
  } else {
668
849
  throw new Error(`Unsupported install target: ${toolMode}`)
669
850
  }
@@ -56,17 +56,19 @@ const BACKUP_PATH = join(homedir(), '.free-coding-models-backups.json')
56
56
  * 📖 Get tool config paths
57
57
  */
58
58
  function getToolConfigPaths(homeDir = homedir()) {
59
- return {
60
- goose: join(homeDir, '.config', 'goose', 'config.yaml'),
61
- crush: join(homeDir, '.config', 'crush', 'crush.json'),
62
- aider: join(homeDir, '.aider.conf.yml'),
63
- kilo: join(homeDir, '.config', 'kilo', 'opencode.json'),
64
- qwen: join(homeDir, '.qwen', 'settings.json'),
65
- piModels: join(homeDir, '.pi', 'agent', 'models.json'),
66
- piSettings: join(homeDir, '.pi', 'agent', 'settings.json'),
67
- openHands: join(homeDir, '.fcm-openhands-env'),
68
- amp: join(homeDir, '.config', 'amp', 'settings.json'),
69
- }
59
+ return {
60
+ goose: join(homeDir, '.config', 'goose', 'config.yaml'),
61
+ crush: join(homeDir, '.config', 'crush', 'crush.json'),
62
+ aider: join(homeDir, '.aider.conf.yml'),
63
+ kilo: join(homeDir, '.config', 'kilo', 'opencode.json'),
64
+ qwen: join(homeDir, '.qwen', 'settings.json'),
65
+ piModels: join(homeDir, '.pi', 'agent', 'models.json'),
66
+ piSettings: join(homeDir, '.pi', 'agent', 'settings.json'),
67
+ openHands: join(homeDir, '.fcm-openhands-env'),
68
+ amp: join(homeDir, '.config', 'amp', 'settings.json'),
69
+ zcodeConfig: join(homeDir, '.zcode', 'v2', 'config.json'),
70
+ zcodeCache: join(homeDir, '.zcode', 'v2', 'bots-model-cache.v2.json'),
71
+ }
70
72
  }
71
73
 
72
74
  /**
@@ -463,6 +465,111 @@ function parseAmpConfig(paths = getToolConfigPaths()) {
463
465
  }
464
466
  }
465
467
 
468
+ /**
469
+ * 📖 Parse ZCode config.json + bots-model-cache.v2.json for installed models.
470
+ * 📖 Uses a Map keyed by "providerId::modelId" to deduplicate across both files.
471
+ */
472
+ function parseZCodeConfig(paths = getToolConfigPaths()) {
473
+ const configPath = paths.zcodeConfig
474
+ const cachePath = paths.zcodeCache
475
+
476
+ if (!existsSync(configPath)) {
477
+ return { isValid: false, models: [], configPath }
478
+ }
479
+
480
+ try {
481
+ /** @type {Map<string, object>} */
482
+ const modelMap = new Map()
483
+
484
+ const configContent = readFileSync(configPath, 'utf8')
485
+ const config = JSON.parse(configContent)
486
+
487
+ // ── 1. Source of truth: config.json provider.models ─────────────────────
488
+ if (config.provider && typeof config.provider === 'object') {
489
+ for (const [providerId, provider] of Object.entries(config.provider)) {
490
+ if (!provider.models || typeof provider.models !== 'object') continue
491
+
492
+ const isManaged = providerId.startsWith('fcm-')
493
+ const sourceKey = isManaged ? providerId.replace('fcm-', '') : providerId
494
+
495
+ for (const [modelId, modelConfig] of Object.entries(provider.models)) {
496
+ const ctx = modelConfig?.limit?.context || 0
497
+ const key = `${providerId}::${modelId}`
498
+ modelMap.set(key, {
499
+ modelId,
500
+ label: modelId,
501
+ tier: '-',
502
+ sweScore: '-',
503
+ providerKey: sourceKey,
504
+ isExternal: !isManaged,
505
+ canLaunch: true,
506
+ contextWindow: ctx,
507
+ enabled: provider.enabled !== false,
508
+ zcodeProviderId: providerId,
509
+ })
510
+ }
511
+ }
512
+ }
513
+
514
+ // ── 2. Cache enrichment: use cache names only (no duplicates) ────────────
515
+ if (existsSync(cachePath)) {
516
+ try {
517
+ const cacheContent = readFileSync(cachePath, 'utf8')
518
+ const cache = JSON.parse(cacheContent)
519
+
520
+ if (Array.isArray(cache.providers)) {
521
+ for (const cachedProvider of cache.providers) {
522
+ if (!cachedProvider?.models) continue
523
+ const providerId = cachedProvider.id || 'unknown'
524
+
525
+ for (const cachedModel of cachedProvider.models) {
526
+ const key = `${providerId}::${cachedModel.id}`
527
+ const existing = modelMap.get(key)
528
+
529
+ if (existing) {
530
+ // 📖 Upgrade label from cache (prettier name) + fill context if missing
531
+ if (cachedModel.name) existing.label = cachedModel.name
532
+ if (cachedModel.contextWindow && !existing.contextWindow) {
533
+ existing.contextWindow = cachedModel.contextWindow
534
+ }
535
+ } else {
536
+ // 📖 Model only in cache (edge case — provider.models missing it)
537
+ const isManaged = providerId.startsWith('fcm-')
538
+ const sourceKey = isManaged ? providerId.replace('fcm-', '') : providerId
539
+ modelMap.set(key, {
540
+ modelId: cachedModel.id,
541
+ label: cachedModel.name || cachedModel.id,
542
+ tier: '-',
543
+ sweScore: '-',
544
+ providerKey: sourceKey,
545
+ isExternal: !isManaged,
546
+ canLaunch: true,
547
+ contextWindow: cachedModel.contextWindow || 0,
548
+ enabled: cachedProvider.enabled !== false,
549
+ zcodeProviderId: providerId,
550
+ })
551
+ }
552
+ }
553
+ }
554
+ }
555
+ } catch {
556
+ // 📖 Cache file is optional — silently ignore errors
557
+ }
558
+ }
559
+
560
+ const models = Array.from(modelMap.values())
561
+
562
+ return {
563
+ isValid: models.length > 0,
564
+ hasManagedMarker: Object.keys(config.provider || {}).some((id) => id.startsWith('fcm-')),
565
+ models,
566
+ configPath,
567
+ }
568
+ } catch (err) {
569
+ return { isValid: false, models: [], configPath }
570
+ }
571
+ }
572
+
466
573
  /**
467
574
  * 📖 Enhance model with metadata from sources.js
468
575
  */
@@ -507,9 +614,11 @@ export function parseToolConfig(toolMode, paths = getToolConfigPaths()) {
507
614
  return parsePiConfig(paths)
508
615
  case 'openhands':
509
616
  return parseOpenHandsConfig(paths)
510
- case 'amp':
511
- return parseAmpConfig(paths)
512
- default:
617
+ case 'zcode':
618
+ return parseZCodeConfig(paths)
619
+ case 'amp':
620
+ return parseAmpConfig(paths)
621
+ default:
513
622
  return { isValid: false, models: [], configPath: '' }
514
623
  }
515
624
  }
@@ -518,7 +627,7 @@ export function parseToolConfig(toolMode, paths = getToolConfigPaths()) {
518
627
  * 📖 Scan all tool configs and return structured results
519
628
  */
520
629
  export function scanAllToolConfigs(paths = getToolConfigPaths()) {
521
- const toolModes = ['goose', 'crush', 'aider', 'kilo', 'qwen', 'pi', 'openhands', 'amp']
630
+ const toolModes = ['goose', 'crush', 'aider', 'kilo', 'qwen', 'pi', 'openhands', 'amp', 'zcode']
522
631
 
523
632
  return toolModes.map((toolMode) => {
524
633
  const result = parseToolConfig(toolMode, paths)
@@ -537,17 +646,18 @@ export function scanAllToolConfigs(paths = getToolConfigPaths()) {
537
646
  * 📖 Get tool emoji
538
647
  */
539
648
  function getToolEmoji(toolMode) {
540
- const emojis = {
541
- goose: '🪿',
542
- crush: '💘',
543
- aider: '🛠',
544
- kilo: '⚡️',
545
- qwen: '🐉',
546
- pi: 'π',
547
- openhands: '🤲',
548
- amp: '⚡',
549
- }
550
- return emojis[toolMode] || '🧰'
649
+ const emojis = {
650
+ goose: '🪿',
651
+ crush: '💘',
652
+ aider: '🛠',
653
+ kilo: '⚡️',
654
+ qwen: '🐉',
655
+ pi: 'π',
656
+ openhands: '🤲',
657
+ amp: '⚡',
658
+ zcode: '🧊',
659
+ }
660
+ return emojis[toolMode] || '🧰'
551
661
  }
552
662
 
553
663
  /**
@@ -580,7 +690,10 @@ function saveBackups(backups) {
580
690
  * 📖 Soft-delete a model from tool config with backup
581
691
  */
582
692
  export function softDeleteModel(toolMode, modelId, paths = getToolConfigPaths()) {
583
- const configPath = paths[toolMode === 'pi' ? 'piSettings' : toolMode]
693
+ const pathKey = toolMode === 'pi' ? 'piSettings'
694
+ : toolMode === 'zcode' ? 'zcodeConfig'
695
+ : toolMode
696
+ const configPath = paths[pathKey]
584
697
  if (!existsSync(configPath)) {
585
698
  return { success: false, error: 'Config file not found' }
586
699
  }
@@ -653,15 +766,58 @@ export function softDeleteModel(toolMode, modelId, paths = getToolConfigPaths())
653
766
  }
654
767
  break
655
768
 
656
- case 'amp':
657
- const ampConfig = JSON.parse(originalContent)
658
- if (ampConfig['amp.model'] === modelId) {
659
- delete ampConfig['amp.model']
660
- newContent = JSON.stringify(ampConfig, null, 2)
661
- modified = true
662
- }
663
- break
664
- }
769
+ case 'zcode': {
770
+ const zconfig = JSON.parse(originalContent)
771
+ // 📖 Find the provider entry that contains this modelId
772
+ let foundProviderId = null
773
+ if (zconfig.provider && typeof zconfig.provider === 'object') {
774
+ for (const [provId, prov] of Object.entries(zconfig.provider)) {
775
+ if (prov.models && typeof prov.models === 'object' && modelId in prov.models) {
776
+ foundProviderId = provId
777
+ break
778
+ }
779
+ }
780
+ }
781
+ if (foundProviderId) {
782
+ delete zconfig.provider[foundProviderId].models[modelId]
783
+ // 📖 If no models left, keep the empty provider (don't remove it — user may want to re-add)
784
+ newContent = JSON.stringify(zconfig, null, 2)
785
+ modified = true
786
+
787
+ // 📖 Also remove from cache file if it exists
788
+ const cachePath = paths.zcodeCache
789
+ if (existsSync(cachePath)) {
790
+ try {
791
+ const cacheContent = readFileSync(cachePath, 'utf8')
792
+ const cache = JSON.parse(cacheContent)
793
+ if (Array.isArray(cache.providers)) {
794
+ const cacheProv = cache.providers.find((p) => p.id === foundProviderId)
795
+ if (cacheProv && Array.isArray(cacheProv.models)) {
796
+ const before = cacheProv.models.length
797
+ cacheProv.models = cacheProv.models.filter((m) => m.id !== modelId)
798
+ if (cacheProv.models.length !== before) {
799
+ cacheProv.updatedAt = Date.now()
800
+ writeFileSync(cachePath, JSON.stringify(cache, null, 2))
801
+ }
802
+ }
803
+ }
804
+ } catch {
805
+ // 📖 Cache file is optional
806
+ }
807
+ }
808
+ }
809
+ break
810
+ }
811
+
812
+ case 'amp':
813
+ const ampConfig = JSON.parse(originalContent)
814
+ if (ampConfig['amp.model'] === modelId) {
815
+ delete ampConfig['amp.model']
816
+ newContent = JSON.stringify(ampConfig, null, 2)
817
+ modified = true
818
+ }
819
+ break
820
+ }
665
821
 
666
822
  if (!modified) {
667
823
  return { success: false, error: 'Model not found in config' }