@polderlabs/bizar 10.20.1 → 10.22.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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `bizar model` subcommands:
5
5
  * list — fetch all models from the configured provider gateway and group
6
- * by provider prefix.
6
+ * by provider family.
7
7
  *
8
8
  * The gateway URL is read from `BIZAR_MODEL_ROUTER_URL` or
9
9
  * `ANTHROPIC_BASE_URL`. If neither is set, the command errors out with a
@@ -14,18 +14,24 @@
14
14
  * makes the /model picker surface only IDs prefixed with "claude" or
15
15
  * "anthropic". This command shows the FULL set including cx/, oc/, and
16
16
  * unprefixed IDs.
17
+ *
18
+ * 10.22.0 / Phase 4 spirit-of-constraint fix: this command used to carry a
19
+ * hardcoded `PROVIDER_GROUPS` prefix list that duplicated the canonical
20
+ * provider family detection in `cli/commands/models.mjs#classifyKind`.
21
+ * It now imports `classifyKind` from there (single source of truth) and
22
+ * filters the operator's `disabledProviders` list using the same
23
+ * case-sensitive prefix match as every other read site — adding a new
24
+ * provider family no longer requires touching this file.
17
25
  */
18
26
  import chalk from 'chalk';
19
27
 
28
+ import { classifyKind, filterCandidatesByDisabledProviders, readDisabledProviders } from './models.mjs';
29
+
20
30
  const BIZAR_MODEL_ROUTER_URL = process.env.BIZAR_MODEL_ROUTER_URL
21
31
  || process.env.ANTHROPIC_BASE_URL
22
32
  || null;
23
33
  const ANTHROPIC_AUTH_TOKEN = process.env.ANTHROPIC_AUTH_TOKEN || null;
24
34
 
25
- const PROVIDER_GROUPS = [
26
- 'cx/', 'claude-minimax/', 'claude-qwen/', 'oc/', 'claude/', 'anthropic/',
27
- ];
28
-
29
35
  function showHelp() {
30
36
  const gatewayDisplay = BIZAR_MODEL_ROUTER_URL ?? '(not configured)';
31
37
  console.log(`
@@ -41,13 +47,17 @@ function showHelp() {
41
47
  The /model picker inside Claude Code shows only "claude"/"anthropic" prefixed
42
48
  IDs. This command exposes the full set (cx/, claude-minimax/, claude-qwen/, oc/, etc.).
43
49
 
50
+ The list honours the operator's \`disabledProviders\` list from
51
+ model-router.json (10.22.0 / Phase 4) — same source of truth as
52
+ \`bizar models\`.
53
+
44
54
  Gateway configuration:
45
55
  Set BIZAR_MODEL_ROUTER_URL or ANTHROPIC_BASE_URL to your provider gateway
46
56
  (e.g. https://router.example.com/v1). If neither is set, this command
47
57
  exits with a configuration error.
48
58
 
49
59
  Flags:
50
- --json Emit { providers: { "cx/": [...], ... }, total: N }
60
+ --json Emit { providers: { "<family>": [...], ... }, total: N }
51
61
  `);
52
62
  }
53
63
 
@@ -94,19 +104,28 @@ async function fetchModels() {
94
104
  }
95
105
 
96
106
  function groupByProvider(models) {
107
+ // 10.22.0 / Phase 4: filter the operator's `disabledProviders` first,
108
+ // then group by the canonical `classifyKind` family name. No
109
+ // hardcoded prefix list lives in this file.
110
+ const ids = models.map((m) => (typeof m?.id === 'string' ? m.id : ''));
111
+ const disabled = readDisabledProviders();
112
+ const { kept } = filterCandidatesByDisabledProviders(ids, disabled);
113
+ const keptSet = new Set(kept);
114
+
97
115
  const groups = {};
98
116
  for (const m of models) {
99
- const id = m.id ?? '';
100
- const prefix = PROVIDER_GROUPS.find(p => id.startsWith(p)) || null;
101
- const key = prefix !== null ? prefix : '(no prefix)';
117
+ const id = typeof m?.id === 'string' ? m.id : '';
118
+ if (!id || !keptSet.has(id)) continue;
119
+ const family = classifyKind(id);
120
+ const key = family || '(unclassified)';
102
121
  if (!groups[key]) groups[key] = [];
103
- groups[key].push({ id, display_name: m.display_name ?? m.id });
122
+ groups[key].push({ id, display_name: m.display_name ?? id });
104
123
  }
105
124
  return groups;
106
125
  }
107
126
 
108
127
  function printTable(groups) {
109
- const colWidths = { provider: 14, id: 50, display_name: 30 };
128
+ const colWidths = { provider: 16, id: 50, display_name: 30 };
110
129
 
111
130
  const header = [
112
131
  'provider'.padEnd(colWidths.provider),
@@ -670,6 +670,134 @@ export function classifyKind(modelId) {
670
670
  return 'other';
671
671
  }
672
672
 
673
+ // ── Disabled providers (10.22.0 / Phase 4) ────────────────────────────────────
674
+ //
675
+ // Operator-controllable provider disable list. Read from the top-level
676
+ // `disabledProviders: string[]` key on the model-router config. There is NO
677
+ // in-code hardcoded list — adding or removing a blocked provider is a
678
+ // single JSON edit.
679
+ //
680
+ // Dual-path read: the Bizar path (`~/.config/bizar/config/claude/model-router.json`)
681
+ // WINS when both exist, including an explicit `[]` (operators may pin
682
+ // "no providers disabled" without deleting the legacy mirror). Whitespace
683
+ // trim + lowercase normalization happens at read time so operators may
684
+ // write `" Anthropic "` in JSON and still match `anthropic/...` model
685
+ // ids. The comparison itself is a case-sensitive prefix filter against
686
+ // the (lowercase) disabled prefixes, so `Anthropic/claude-X` (capital A)
687
+ // is intentionally NOT stripped — pin test covers that.
688
+
689
+ /**
690
+ * Normalize a single disabled-provider prefix: trim whitespace, lowercase.
691
+ * Returns empty string for non-string / empty input.
692
+ * @param {unknown} raw
693
+ * @returns {string}
694
+ */
695
+ function normalizeDisabledPrefix(raw) {
696
+ if (typeof raw !== 'string') return '';
697
+ const trimmed = raw.trim();
698
+ if (!trimmed) return '';
699
+ return trimmed.toLowerCase();
700
+ }
701
+
702
+ /**
703
+ * Read `disabledProviders` from a parsed router object. Returns the
704
+ * normalized prefix array (or empty array when missing / malformed).
705
+ * @param {unknown} router
706
+ * @returns {string[]}
707
+ */
708
+ function extractDisabledProviders(router) {
709
+ if (!router || typeof router !== 'object' || Array.isArray(router)) return [];
710
+ const raw = router.disabledProviders;
711
+ if (!Array.isArray(raw)) return [];
712
+ const out = [];
713
+ for (const v of raw) {
714
+ const norm = normalizeDisabledPrefix(v);
715
+ if (norm) out.push(norm);
716
+ }
717
+ return out;
718
+ }
719
+
720
+ /**
721
+ * Read the operator's `disabledProviders` list from the model-router
722
+ * config. Dual-path: the Bizar path wins when present (even with an
723
+ * explicit empty array); the legacy `~/.claude/model-router.json` mirror
724
+ * is the fallback. Both paths are normalized (trim + lowercase) at read.
725
+ *
726
+ * Pure function over the filesystem; returns an empty array when neither
727
+ * file exists, when both files lack the key, or when both reads fail.
728
+ *
729
+ * @param {{ routerPath?: string, legacyPath?: string }} [opts]
730
+ * - `routerPath` defaults to the Bizar home path
731
+ * (`~/.config/bizar/config/claude/model-router.json`).
732
+ * - `legacyPath` defaults to the Claude Code mirror
733
+ * (`~/.claude/model-router.json`).
734
+ * @returns {string[]} normalized disabled-provider prefixes
735
+ */
736
+ export function readDisabledProviders({ routerPath, legacyPath } = {}) {
737
+ const bizarPath = routerPath || join(homedir(), '.config', 'bizar', 'config', 'claude', 'model-router.json');
738
+ const fallPath = legacyPath || join(homedir(), '.claude', 'model-router.json');
739
+ if (existsSync(bizarPath)) {
740
+ try {
741
+ const parsed = JSON.parse(readFileSync(bizarPath, 'utf8'));
742
+ const extracted = extractDisabledProviders(parsed);
743
+ // Bizar path exists — its `disabledProviders` is authoritative even
744
+ // when explicitly `[]` (operators may pin "no providers disabled"
745
+ // without deleting the legacy mirror). Missing key still falls back.
746
+ if (Array.isArray(parsed && typeof parsed === 'object' ? parsed.disabledProviders : undefined)) {
747
+ return extracted;
748
+ }
749
+ } catch {
750
+ // Corrupt Bizar file — fall through to the legacy mirror.
751
+ }
752
+ }
753
+ if (existsSync(fallPath)) {
754
+ try {
755
+ const parsed = JSON.parse(readFileSync(fallPath, 'utf8'));
756
+ return extractDisabledProviders(parsed);
757
+ } catch {
758
+ return [];
759
+ }
760
+ }
761
+ return [];
762
+ }
763
+
764
+ /**
765
+ * Filter a candidate id list against the disabled-providers prefix list.
766
+ *
767
+ * The comparison is a strict, case-sensitive prefix match against the
768
+ * NORMALIZED (lowercase) disabled prefixes. Candidate ids are checked
769
+ * as-given, so a candidate like `Anthropic/claude-X` (capital A) is
770
+ * intentionally NOT stripped when `disabledProviders` is `["anthropic"]`
771
+ * — pin test covers that. Operators who want to block mixed-case ids
772
+ * should write the lowercase form.
773
+ *
774
+ * Empty / missing `disabledProviders` is a no-op (returns the input list).
775
+ *
776
+ * @template T extends string
777
+ * @param {T[] | Iterable<T>} candidates
778
+ * @param {string[]} disabledProviders already-normalized prefixes
779
+ * @returns {{ kept: T[], stripped: T[] }}
780
+ */
781
+ export function filterCandidatesByDisabledProviders(candidates, disabledProviders) {
782
+ const list = Array.isArray(candidates) ? candidates : Array.from(candidates || []);
783
+ const prefixes = Array.isArray(disabledProviders)
784
+ ? disabledProviders.filter((p) => typeof p === 'string' && p.length > 0)
785
+ : [];
786
+ if (prefixes.length === 0) return { kept: [...list], stripped: [] };
787
+ const kept = [];
788
+ const stripped = [];
789
+ for (const id of list) {
790
+ const s = typeof id === 'string' ? id : '';
791
+ let blocked = false;
792
+ for (const p of prefixes) {
793
+ if (s && s.startsWith(p)) { blocked = true; break; }
794
+ }
795
+ if (blocked) stripped.push(id);
796
+ else kept.push(id);
797
+ }
798
+ return { kept, stripped };
799
+ }
800
+
673
801
  // ── Persistence ──────────────────────────────────────────────────────────────
674
802
 
675
803
  /**
@@ -692,8 +820,16 @@ export function loadRouter(routerPath) {
692
820
  * @param {{ routerPath: string, models: string[], tierHints?: Record<string,string>, profiles?: Record<string,object>, source?: string }} opts
693
821
  * @returns {{ models: string[], lastUpdated: string, source: string, tierHints: Record<string,string>, profiles: Record<string,object> }}
694
822
  */
695
- export function applyModels({ routerPath, models, tierHints = {}, profiles = {}, source = 'live-pick' }) {
696
- const list = Array.isArray(models) ? models.filter((m) => typeof m === 'string' && m.trim()) : [];
823
+ export function applyModels({ routerPath, models, tierHints = {}, profiles = {}, source = 'live-pick', disabledProviders }) {
824
+ const incoming = Array.isArray(models) ? models.filter((m) => typeof m === 'string' && m.trim()) : [];
825
+ // 10.22.0 / Phase 4: strip any id whose provider prefix is on the
826
+ // operator's disabled-providers list. The router file owns the list —
827
+ // no in-code hardcoded families. Stripped ids never reach the picker,
828
+ // the settings.json sync, the SessionStart hook, or the Agent guard.
829
+ // Tests pass an explicit `disabledProviders` array to keep the contract
830
+ // deterministic; production callers omit it and read from disk.
831
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
832
+ const { kept: list } = filterCandidatesByDisabledProviders(incoming, disabled);
697
833
  const hints = { ...(tierHints || {}) };
698
834
  for (const id of list) if (!hints[id]) hints[id] = defaultTierHint(id);
699
835
  const block = {
@@ -739,9 +875,14 @@ function writeAtomic(path, body) {
739
875
  * - `staleIds` — picks that were never returned by the gateway in this run.
740
876
  * - `unknownIds` — picks whose live status is unknown (no live pool yet).
741
877
  */
742
- export function partitionStalePicks({ liveIds, pickedIds }) {
878
+ export function partitionStalePicks({ liveIds, pickedIds, disabledProviders }) {
743
879
  const live = Array.isArray(liveIds) ? new Set(liveIds) : null;
744
- const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
880
+ const incoming = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
881
+ // 10.22.0 / Phase 4: strip disabled-provider ids before partition so
882
+ // they never reach the live pool OR the stale list — they were never
883
+ // the operator's intent once the disable list landed.
884
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
885
+ const { kept: picks } = filterCandidatesByDisabledProviders(incoming, disabled);
745
886
  if (!live || live.size === 0) {
746
887
  return { liveIds: [], staleIds: [], unknownIds: [...new Set(picks)] };
747
888
  }
@@ -782,18 +923,25 @@ export function partitionStalePicks({ liveIds, pickedIds }) {
782
923
  * wrote: boolean,
783
924
  * syncedIds: string[],
784
925
  * skippedStale: string[],
926
+ * skippedDisabled: string[],
785
927
  * settingsPath: string|null,
786
928
  * }}
787
929
  */
788
- export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = [] }) {
930
+ export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = [], disabledProviders }) {
789
931
  const path = settingsJsonPath === undefined
790
932
  ? join(homedir(), '.claude', 'settings.json')
791
933
  : settingsJsonPath;
792
934
  if (path === null) {
793
- return { wrote: false, syncedIds: [], skippedStale: [], settingsPath: null };
935
+ return { wrote: false, syncedIds: [], skippedStale: [], skippedDisabled: [], settingsPath: null };
794
936
  }
795
937
  const live = new Set(Array.isArray(liveIds) ? liveIds : []);
796
- const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
938
+ const incoming = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
939
+ // 10.22.0 / Phase 4: strip disabled-provider ids BEFORE the self-map
940
+ // so Claude Code never sees an `anthropic/*` self-map entry. The
941
+ // skipped ids are reported back so the operator can see what was
942
+ // dropped (without crashing on the disabled list).
943
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
944
+ const { kept: picks, stripped: skippedDisabled } = filterCandidatesByDisabledProviders(incoming, disabled);
797
945
  // Self-map only IDs the live gateway serves. Stale IDs intentionally stay
798
946
  // out so the `[claude-code:unrecognized_model]` diagnostic still fires
799
947
  // for them — the operator should re-run `bizar models` to drop them.
@@ -810,14 +958,14 @@ export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = []
810
958
  } catch {
811
959
  // Corrupt settings.json — refuse to overwrite it; surface the sync
812
960
  // as a no-op so `bizar models` still completes.
813
- return { wrote: false, syncedIds: [], skippedStale: skipped, settingsPath: path };
961
+ return { wrote: false, syncedIds: [], skippedStale: skipped, skippedDisabled, settingsPath: path };
814
962
  }
815
963
  }
816
964
  // Self-map pattern — Claude Code uses modelOverrides to suppress
817
965
  // `[claude-code:unrecognized_model]` for any ID that maps to itself.
818
966
  settings.modelOverrides = Object.fromEntries(synced.map((id) => [id, id]));
819
967
  writeAtomic(path, JSON.stringify(settings, null, 2) + '\n');
820
- return { wrote: true, syncedIds: synced, skippedStale: skipped, settingsPath: path };
968
+ return { wrote: true, syncedIds: synced, skippedStale: skipped, skippedDisabled, settingsPath: path };
821
969
  }
822
970
 
823
971
  /**
@@ -893,18 +1041,24 @@ export function deriveModelLabel(modelId, profile) {
893
1041
  * wrote: boolean,
894
1042
  * options: Array<{model: string, label: string, description?: string}>,
895
1043
  * skippedStale: string[],
1044
+ * skippedDisabled: string[],
896
1045
  * settingsPath: string|null,
897
1046
  * }}
898
1047
  */
899
- export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, liveIds = [] }) {
1048
+ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, liveIds = [], disabledProviders }) {
900
1049
  const path = settingsJsonPath === undefined
901
1050
  ? join(homedir(), '.claude', 'settings.json')
902
1051
  : settingsJsonPath;
903
1052
  if (path === null) {
904
- return { wrote: false, options: [], skippedStale: [], settingsPath: null };
1053
+ return { wrote: false, options: [], skippedStale: [], skippedDisabled: [], settingsPath: null };
905
1054
  }
906
1055
  const live = new Set(Array.isArray(liveIds) ? liveIds : []);
907
- const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
1056
+ const incoming = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
1057
+ // 10.22.0 / Phase 4: drop disabled-provider ids BEFORE the live-id
1058
+ // gate so Claude Code's `/model` picker never surfaces an
1059
+ // `anthropic/*` (or any other disabled) option.
1060
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
1061
+ const { kept: picks, stripped: skippedDisabled } = filterCandidatesByDisabledProviders(incoming, disabled);
908
1062
  const surviving = live.size === 0 ? picks : picks.filter((id) => live.has(id));
909
1063
  const skipped = live.size === 0 ? [] : picks.filter((id) => !live.has(id));
910
1064
  const options = surviving.map((id) => {
@@ -922,12 +1076,12 @@ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, l
922
1076
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
923
1077
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) settings = parsed;
924
1078
  } catch {
925
- return { wrote: false, options: [], skippedStale: skipped, settingsPath: path };
1079
+ return { wrote: false, options: [], skippedStale: skipped, skippedDisabled, settingsPath: path };
926
1080
  }
927
1081
  }
928
1082
  settings.modelPicker = { options };
929
1083
  writeAtomic(path, JSON.stringify(settings, null, 2) + '\n');
930
- return { wrote: true, options, skippedStale: skipped, settingsPath: path };
1084
+ return { wrote: true, options, skippedStale: skipped, skippedDisabled, settingsPath: path };
931
1085
  }
932
1086
 
933
1087
  // ── F-190 / IMP-017 refresh + operator-override preservation ────────────────
@@ -956,7 +1110,7 @@ function profileNeedsRefresh(profile, now = new Date()) {
956
1110
  * `bizar models --refresh` prints in non-interactive mode.
957
1111
  *
958
1112
  * @param {{ routerPath: string, candidates: Array<{ id: string, profile?: object }>, catalog: object, aliasMap?: object, now?: Date }} opts
959
- * @returns {{ refreshed: string[], preservedOperator: string[], skippedFresh: string[], error?: string }}
1113
+ * @returns {{ refreshed: string[], preservedOperator: string[], skippedFresh: string[], skippedDisabled: string[], error?: string }}
960
1114
  */
961
1115
  export function applyRefresh({
962
1116
  routerPath,
@@ -964,6 +1118,7 @@ export function applyRefresh({
964
1118
  catalog,
965
1119
  aliasMap = {},
966
1120
  now = new Date(),
1121
+ disabledProviders,
967
1122
  } = {}) {
968
1123
  if (!routerPath || typeof routerPath !== 'string') throw new Error('routerPath is required');
969
1124
  if (!Array.isArray(candidates)) throw new Error('candidates is required (array)');
@@ -979,15 +1134,29 @@ export function applyRefresh({
979
1134
  const existingProfiles = userSelected.profiles && typeof userSelected.profiles === 'object'
980
1135
  ? userSelected.profiles
981
1136
  : {};
982
- const existingModels = Array.isArray(userSelected.models) ? userSelected.models : [];
1137
+ const rawExistingModels = Array.isArray(userSelected.models) ? userSelected.models : [];
983
1138
  const existingTierHints = userSelected.tierHints && typeof userSelected.tierHints === 'object'
984
1139
  ? userSelected.tierHints
985
1140
  : {};
986
1141
 
1142
+ // 10.22.0 / Phase 4: filter disabled-provider ids off the existing
1143
+ // list BEFORE the refresh loop so the disabled ids never get a
1144
+ // refresh attempt, are not in the returned counts, and the persisted
1145
+ // userSelected.models reflects the operator's intent. Dropped profiles
1146
+ // are also pruned so the on-disk state does not keep growing on every
1147
+ // refresh.
1148
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
1149
+ const { kept: existingModels, stripped: droppedDisabledIds } = filterCandidatesByDisabledProviders(
1150
+ rawExistingModels.filter((id) => typeof id === 'string' && id.trim()),
1151
+ disabled,
1152
+ );
1153
+ const newProfiles = { ...existingProfiles };
1154
+ for (const dropped of droppedDisabledIds) delete newProfiles[dropped];
1155
+
987
1156
  const refreshed = [];
988
1157
  const preservedOperator = [];
989
1158
  const skippedFresh = [];
990
- const newProfiles = { ...existingProfiles };
1159
+ const skippedDisabled = [...droppedDisabledIds];
991
1160
 
992
1161
  for (const id of existingModels) {
993
1162
  if (typeof id !== 'string' || !id.trim()) continue;
@@ -1050,7 +1219,7 @@ export function applyRefresh({
1050
1219
  // the picker write path above.
1051
1220
  writeAtomic(routerPath, JSON.stringify(router, null, 2) + '\n');
1052
1221
 
1053
- return { refreshed, preservedOperator, skippedFresh };
1222
+ return { refreshed, preservedOperator, skippedFresh, skippedDisabled };
1054
1223
  }
1055
1224
 
1056
1225
  function safeParseRouter(path) {
@@ -1149,11 +1318,17 @@ function stampProvenance(profile, now) {
1149
1318
  * Read the user-selected block off the router. Returns the models array
1150
1319
  * (possibly empty) and the tier hints.
1151
1320
  */
1152
- export function currentSelection(router) {
1321
+ export function currentSelection(router, { disabledProviders } = {}) {
1153
1322
  if (!router || typeof router !== 'object') return { models: [], tierHints: {} };
1154
1323
  const us = router.userSelected;
1155
1324
  if (!us || typeof us !== 'object') return { models: [], tierHints: {} };
1156
- const models = Array.isArray(us.models) ? us.models.filter((m) => typeof m === 'string') : [];
1325
+ const incoming = Array.isArray(us.models) ? us.models.filter((m) => typeof m === 'string') : [];
1326
+ // 10.22.0 / Phase 4: also strip disabled-provider ids on every read.
1327
+ // Callers (interactive picker, JSON envelope, audit tools) all consume
1328
+ // the filtered list so the operator's disable intent is honoured even
1329
+ // for ids already persisted in `userSelected.models` from a prior run.
1330
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
1331
+ const { kept: models } = filterCandidatesByDisabledProviders(incoming, disabled);
1157
1332
  const tierHints = us.tierHints && typeof us.tierHints === 'object' ? us.tierHints : {};
1158
1333
  return { models, tierHints };
1159
1334
  }
@@ -1770,6 +1945,16 @@ function showHelp() {
1770
1945
  a default. Bizar is provider-agnostic and ships no default gateway.
1771
1946
 
1772
1947
  Auth: $ANTHROPIC_AUTH_TOKEN -> settings.json#env.ANTHROPIC_AUTH_TOKEN.\n\n Discovered models are enriched from https://models.dev/models.json.\n Metadata lookup is best-effort and never hides gateway-reported models.
1948
+
1949
+ Disable providers (10.22.0 / Phase 4): the operator's
1950
+ model-router.json#disabledProviders: string[]
1951
+ list filters out every id whose provider prefix matches (case-sensitive,
1952
+ trimmed + lowercased at read time). The filter is consulted at every
1953
+ reader site: the picker (interactive + --list), settings.json#model
1954
+ and #modelOverrides, settings.json#modelPicker.options, the
1955
+ SessionStart sync, and the Agent model guard. Empty / missing list is
1956
+ a no-op (every id passes through). Add or remove a blocked provider
1957
+ with a single JSON edit; no in-code list exists.
1773
1958
  `;
1774
1959
  console.log(help);
1775
1960
  }
@@ -11,7 +11,7 @@
11
11
  * 2. Sets the permissive defaults the Bizar user typically wants:
12
12
  * - permissions.defaultMode → "bypassPermissions"
13
13
  * - worktree.bgIsolation → "worktree"
14
- * - model → "claude/minimax/MiniMax-M3"
14
+ * - model → userSelected.models[0] (10.22.0 / Phase 4)
15
15
  * - alwaysThinkingEnabled → true
16
16
  * - effortLevel → "high"
17
17
  * - skipDangerousModePermissionPrompt → true
@@ -21,6 +21,9 @@
21
21
  * a more restrictive mode (e.g. a user who set
22
22
  * defaultMode="default" gets to keep it).
23
23
  * 4. Preserves every unrelated key (mcpServers, hooks, env, etc.).
24
+ * 5. Skips `model` entirely when userSelected is empty — Claude Code
25
+ * inherits its session default, still gated by the operator's
26
+ * `disabledProviders` list (10.22.0 / Phase 4).
24
27
  *
25
28
  * Why this exists: the canonical config/claude/settings.json ships with
26
29
  * these defaults and `cli/provision.mjs` (F-163 scope-owned) is the
@@ -37,11 +40,39 @@ import { join } from 'node:path';
37
40
  const HOME = homedir();
38
41
  const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR?.trim() || join(HOME, '.claude');
39
42
  const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
43
+ // 10.22.0 / Phase 4 spirit-of-constraint: the install model id comes
44
+ // from the operator's `userSelected.models[0]`, not a hardcoded literal.
45
+ // Dual-path read matches `cli/commands/models.mjs#readDisabledProviders`:
46
+ // the Bizar path wins when present; the Claude Code mirror is fallback.
47
+ const BIZAR_ROUTER_PATH = process.env.BIZAR_MODEL_ROUTER_CONFIG?.trim()
48
+ || join(HOME, '.config', 'bizar', 'config', 'claude', 'model-router.json');
49
+ const LEGACY_ROUTER_PATH = join(HOME, '.claude', 'model-router.json');
50
+
51
+ function readUserSelectedModels() {
52
+ for (const path of [BIZAR_ROUTER_PATH, LEGACY_ROUTER_PATH]) {
53
+ if (!existsSync(path)) continue;
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
56
+ const list = Array.isArray(parsed?.userSelected?.models)
57
+ ? parsed.userSelected.models.filter((id) => typeof id === 'string' && id.trim())
58
+ : [];
59
+ if (list.length > 0) return list;
60
+ // Bizar path exists with an explicit empty `userSelected.models` —
61
+ // honour that intent (do NOT fall through to the legacy mirror).
62
+ if (path === BIZAR_ROUTER_PATH && parsed && typeof parsed === 'object'
63
+ && Array.isArray(parsed.userSelected?.models)) {
64
+ return [];
65
+ }
66
+ } catch { /* keep falling through */ }
67
+ }
68
+ return [];
69
+ }
40
70
 
41
- const FAVORED = {
71
+ const STATIC_FAVORED = {
42
72
  permissions: { defaultMode: 'bypassPermissions' },
43
73
  worktree: { bgIsolation: 'worktree' },
44
- model: 'claude/minimax/MiniMax-M3',
74
+ // `model` is intentionally absent — `buildFavored()` derives it from
75
+ // userSelected.models[0] (or omits it when empty).
45
76
  alwaysThinkingEnabled: true,
46
77
  effortLevel: 'high',
47
78
  skipDangerousModePermissionPrompt: true,
@@ -49,6 +80,13 @@ const FAVORED = {
49
80
  askUserQuestionTimeout: '5m',
50
81
  };
51
82
 
83
+ function buildFavored() {
84
+ const out = { ...STATIC_FAVORED };
85
+ const picks = readUserSelectedModels();
86
+ if (picks[0]) out.model = picks[0];
87
+ return out;
88
+ }
89
+
52
90
  function isAtLeastAsPermissive(existing, desired) {
53
91
  // Don't downgrade: if the user picked a stricter mode, keep theirs.
54
92
  const order = { default: 0, acceptEdits: 1, bypassPermissions: 2 };
@@ -86,10 +124,11 @@ function load() {
86
124
 
87
125
  function diff(existing) {
88
126
  const proposed = JSON.parse(JSON.stringify(existing));
89
- deepAssign(proposed, FAVORED);
127
+ const favored = buildFavored();
128
+ deepAssign(proposed, favored);
90
129
  // Permission-mode guard.
91
130
  const existingMode = existing?.permissions?.defaultMode;
92
- if (existingMode && !isAtLeastAsPermissive(existingMode, FAVORED.permissions.defaultMode)) {
131
+ if (existingMode && !isAtLeastAsPermissive(existingMode, favored.permissions.defaultMode)) {
93
132
  proposed.permissions.defaultMode = existingMode;
94
133
  }
95
134
  return proposed;