free-coding-models 0.5.36 → 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,14 @@
1
+ # Changelog v0.5.37 - 2026-06-20
2
+
3
+ ### Changed
4
+ - **Router `score` is now a pure latency+uptime composite (issue #120 hardening).** Previously the routing score was `0.4*latency + 0.4*uptime + 0.2*priorityBonus` — mixing explicit priority into the score could mislead tiebreakers and dashboards, because the routing comparator in `getRoutingCandidates` already enforces priority authoritatively. `scoreCandidates()` now exposes `score = 0.5*latency + 0.5*uptime` (pure model quality, normalized to `[0, 1]`), and `priorityBonus` is kept as a separate field for back-compat dashboards and legacy UIs. The remaining `latencyWeight` / `uptimeWeight` were rebalanced to `0.5 / 0.5` so the score stays in `[0, 1]` after priority is removed. Applies uniformly to CLI, Web Dashboard, and Desktop surfaces because the change lives in shared core (`src/core/router-daemon.js` + `src/core/config.js`).
5
+
6
+ ### Fixed
7
+ - **HALF_OPEN recovery is now respected by the priority-first comparator (issue #120 regression test).** The v0.5.36 fix changed `getRoutingCandidates` to sort by explicit priority before circuit state, but had no test for the exact scenario from the issue screenshot: a high-priority model sitting in `HALF_OPEN` recovery vs. a lower-priority `CLOSED` model. Added a regression test that pins `runtime.circuit.get(key).state = 'HALF_OPEN'` for priority `#1` while priority `#5` stays `CLOSED`, then verifies both `/stats.routingOrder[0]` and the actual chat-completions response target the HALF_OPEN priority-#1 model — locking in the fix so it can't silently regress.
8
+ - **Score tiebreaker is deterministic when two models share the same priority (issue #120 regression test).** Added a second regression test that puts two models at the same explicit priority (rare but reachable via direct API or auto-heal) with deliberately asymmetric probe data — fast groq (80 ms) vs. slow nvidia (2000 ms) — and verifies that the higher-score model wins `routingOrder[0]`. This locks in the new pure-latency+uptime score as the deterministic tiebreaker for same-priority same-state candidates, preventing future regressions where Map iteration order or stale priority data could leak back into routing.
9
+
10
+ ### Docs
11
+ - **`DEFAULT_ROUTER_SETTINGS.scoring.priorityWeight` marked as preserved-for-back-compat.** The field still round-trips through `normalizeRouterScoring()` so user configs that customize it are not silently dropped on next save, but it is now ignored by the runtime `scoreCandidates()`. Will be removed in a future major bump. Comment block in `src/core/config.js` documents the rationale.
12
+
13
+ ### Tests
14
+ - **+2 new tests for issue #120** (`test/test.js`, `router daemon integration hardening` suite): `keeps a higher-priority HALF_OPEN model above a lower-priority CLOSED one` and `breaks score ties deterministically by latency/uptime, not priority`. Test count moves from 540 → 542, all passing.
@@ -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.36",
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",
@@ -167,8 +167,15 @@ export const DEFAULT_ROUTER_SETTINGS = Object.freeze({
167
167
  requestTimeoutMs: 15000,
168
168
  }),
169
169
  scoring: Object.freeze({
170
- latencyWeight: 0.4,
171
- uptimeWeight: 0.4,
170
+ latencyWeight: 0.5,
171
+ uptimeWeight: 0.5,
172
+ // 📖 priorityWeight is preserved for back-compat with user configs that
173
+ // 📖 set a custom value, but is no longer mixed into the routing score
174
+ // 📖 (issue #120 fix, v0.5.37). Priority is now enforced authoritatively
175
+ // 📖 by the comparator in getRoutingCandidates; folding it into score
176
+ // 📖 could only mislead tiebreakers and dashboards. The remaining
177
+ // 📖 latency+uptime weights are rebalanced to 0.5/0.5 so the score stays
178
+ // 📖 in [0, 1] after priority is removed (previously 0.4/0.4/0.2).
172
179
  priorityWeight: 0.2,
173
180
  }),
174
181
  logLevel: 'info',
@@ -377,6 +384,10 @@ function normalizeRouterScoring(scoring) {
377
384
  const numeric = Number(value)
378
385
  return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback
379
386
  }
387
+ // 📖 priorityWeight is normalized but currently IGNORED by the runtime
388
+ // 📖 scoreCandidates() (issue #120, v0.5.37). Kept here so existing user
389
+ // 📖 configs that customize this field round-trip cleanly and don't lose
390
+ // 📖 their setting on next save. Will be removed in a future major bump.
380
391
  return {
381
392
  latencyWeight: numberOrDefault(safeScoring.latencyWeight, DEFAULT_ROUTER_SETTINGS.scoring.latencyWeight),
382
393
  uptimeWeight: numberOrDefault(safeScoring.uptimeWeight, DEFAULT_ROUTER_SETTINGS.scoring.uptimeWeight),
@@ -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' }
@@ -1149,15 +1149,27 @@ class RouterRuntime {
1149
1149
  const hasData = stats.total > 0
1150
1150
  const latencyScore = stats.p95 === null ? 0.5 : Math.max(0, 1 - (stats.p95 / maxP95))
1151
1151
  const uptimeScore = stats.uptime === null ? 0.5 : stats.uptime
1152
+ // 📖 priorityBonus - kept as a separate field for dashboards/legacy UIs
1153
+ // 📖 that previously rendered a single composite "score". Priority is
1154
+ // 📖 NOT folded into `score` anymore (issue #120): the routing comparator
1155
+ // 📖 in getRoutingCandidates sorts by explicit priority authoritatively,
1156
+ // 📖 so mixing priority into the score only confused tiebreakers.
1152
1157
  const priorityBonus = 1 - ((entry.priority - 1) / setSize)
1158
+ // 📖 score - pure latency+uptime composite. Used only as the FINAL
1159
+ // 📖 tiebreaker between candidates that share the same priority AND
1160
+ // 📖 same circuit state (see getRoutingCandidates). A model with no
1161
+ // 📖 probe data yet scores neutral (0.5) - we deliberately do NOT use
1162
+ // 📖 priorityBonus as a cold-start fallback, because that would re-
1163
+ // 📖 introduce the priority-in-score confusion this refactor removes.
1153
1164
  const score = hasData
1154
- ? (weights.latencyWeight * latencyScore) + (weights.uptimeWeight * uptimeScore) + (weights.priorityWeight * priorityBonus)
1155
- : priorityBonus
1165
+ ? (weights.latencyWeight * latencyScore) + (weights.uptimeWeight * uptimeScore)
1166
+ : 0.5
1156
1167
  const state = this.updateCircuitForCooldown(key) || {}
1157
1168
  return {
1158
1169
  ...entry,
1159
1170
  key,
1160
1171
  score,
1172
+ priorityBonus,
1161
1173
  stats,
1162
1174
  circuit: state,
1163
1175
  catalog: this.modelCatalog.get(key) || null,