@polderlabs/bizar 10.21.0 → 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.
- package/cli/commands/model.mjs +30 -11
- package/cli/commands/models.mjs +205 -20
- package/cli/commands/upgrade-defaults.mjs +44 -5
- package/cli/provision.mjs +57 -1
- package/config/claude/agents/office-manager.md +5 -3
- package/config/claude/hooks/agent-model-guard.mjs +62 -3
- package/config/claude/hooks/sessionstart-model-sync.mjs +73 -2
- package/config/claude/model-router.json +2 -1
- package/config/claude/settings.json +0 -4
- package/package.json +1 -1
- package/packages/sdk/dist/version.d.ts +1 -1
- package/packages/sdk/dist/version.js +1 -1
- package/packages/sdk/package.json +1 -1
package/cli/commands/model.mjs
CHANGED
|
@@ -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
|
|
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: { "
|
|
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
|
-
|
|
101
|
-
const
|
|
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 ??
|
|
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:
|
|
128
|
+
const colWidths = { provider: 16, id: 50, display_name: 30 };
|
|
110
129
|
|
|
111
130
|
const header = [
|
|
112
131
|
'provider'.padEnd(colWidths.provider),
|
package/cli/commands/models.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 →
|
|
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
|
|
71
|
+
const STATIC_FAVORED = {
|
|
42
72
|
permissions: { defaultMode: 'bypassPermissions' },
|
|
43
73
|
worktree: { bgIsolation: 'worktree' },
|
|
44
|
-
model
|
|
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
|
-
|
|
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,
|
|
131
|
+
if (existingMode && !isAtLeastAsPermissive(existingMode, favored.permissions.defaultMode)) {
|
|
93
132
|
proposed.permissions.defaultMode = existingMode;
|
|
94
133
|
}
|
|
95
134
|
return proposed;
|
package/cli/provision.mjs
CHANGED
|
@@ -813,6 +813,13 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
|
|
|
813
813
|
// source of truth for defaultMode, worktree, enableWorkflows, etc. We
|
|
814
814
|
// overlay Bizar-owned keys (mcpServers, hooks, env) on top so the installer
|
|
815
815
|
// honors user preferences without having to fork the template here.
|
|
816
|
+
// 10.22.0 / Phase 4 spirit-of-constraint fix: `model` and
|
|
817
|
+
// `modelOverrides` are no longer hardcoded in the shipped template;
|
|
818
|
+
// they are derived from the operator's persisted
|
|
819
|
+
// `userSelected.models[0]`. When the operator has not picked anything
|
|
820
|
+
// yet, both keys are omitted entirely — Claude Code inherits its
|
|
821
|
+
// session default, still gated by the operator's `disabledProviders`
|
|
822
|
+
// list (10.22.0 / Phase 4).
|
|
816
823
|
const bizarSettings = {
|
|
817
824
|
...shipped,
|
|
818
825
|
$schema: shipped.$schema || 'https://json.schemastore.org/claude-code-settings.json',
|
|
@@ -901,6 +908,37 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
|
|
|
901
908
|
},
|
|
902
909
|
};
|
|
903
910
|
|
|
911
|
+
// 10.22.0 / Phase 4: derive `model` and `modelOverrides` from the
|
|
912
|
+
// operator's `userSelected.models[0]`. Both keys are omitted entirely
|
|
913
|
+
// when the operator has not picked anything — Claude Code inherits its
|
|
914
|
+
// session default. Dual-path read matches
|
|
915
|
+
// `cli/commands/models.mjs#readDisabledProviders` and
|
|
916
|
+
// `cli/commands/upgrade-defaults.mjs#readUserSelectedModels`.
|
|
917
|
+
const bizarRouterPath = process.env.BIZAR_MODEL_ROUTER_CONFIG?.trim()
|
|
918
|
+
|| join(BIZAR_HOME(), 'config', 'claude', 'model-router.json');
|
|
919
|
+
const legacyRouterPath = join(CLAUDE_DIR, 'model-router.json');
|
|
920
|
+
let userSelectedModels = [];
|
|
921
|
+
for (const p of [bizarRouterPath, legacyRouterPath]) {
|
|
922
|
+
if (!existsSync(p)) continue;
|
|
923
|
+
const parsed = readJsonSafe(p, null);
|
|
924
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
925
|
+
const list = Array.isArray(parsed?.userSelected?.models)
|
|
926
|
+
? parsed.userSelected.models.filter((id) => typeof id === 'string' && id && id.trim())
|
|
927
|
+
: [];
|
|
928
|
+
if (list.length > 0) { userSelectedModels = list; break; }
|
|
929
|
+
// Bizar path exists with explicit empty `userSelected.models` —
|
|
930
|
+
// honour that intent and stop the fallback walk.
|
|
931
|
+
if (p === bizarRouterPath) break;
|
|
932
|
+
}
|
|
933
|
+
const installModel = userSelectedModels[0];
|
|
934
|
+
if (installModel) {
|
|
935
|
+
bizarSettings.model = installModel;
|
|
936
|
+
bizarSettings.modelOverrides = { [installModel]: installModel };
|
|
937
|
+
} else {
|
|
938
|
+
delete bizarSettings.model;
|
|
939
|
+
delete bizarSettings.modelOverrides;
|
|
940
|
+
}
|
|
941
|
+
|
|
904
942
|
const merged = { ...existing };
|
|
905
943
|
if (force) {
|
|
906
944
|
Object.assign(merged, bizarSettings);
|
|
@@ -1108,7 +1146,25 @@ export async function runProvision(opts = {}) {
|
|
|
1108
1146
|
if (anyFail) console.log(chalk.yellow(' ⚠ Some steps had issues.'));
|
|
1109
1147
|
else { console.log(chalk.bold.green(' ✓ Bizar is ready.')); console.log(chalk.dim(' Next: restart your Claude Code session.')); }
|
|
1110
1148
|
console.log('');
|
|
1111
|
-
|
|
1149
|
+
// 10.22.0 / Phase 4 spirit-of-constraint fix: derive the install-banner
|
|
1150
|
+
// premium model id from the operator's persisted
|
|
1151
|
+
// `userSelected.tierHints.premium[0]` instead of hardcoding one
|
|
1152
|
+
// provider's id. When the operator has not picked anything yet, fall
|
|
1153
|
+
// back to a clear hint to run `bizar models`.
|
|
1154
|
+
let premiumPick = null;
|
|
1155
|
+
try {
|
|
1156
|
+
const routerPath = join(BIZAR_HOME(), 'config', 'claude', 'model-router.json');
|
|
1157
|
+
const router = JSON.parse(readFileSync(routerPath, 'utf8'));
|
|
1158
|
+
const picks = Array.isArray(router?.userSelected?.tierHints?.premium)
|
|
1159
|
+
? router.userSelected.tierHints.premium.filter((id) => typeof id === 'string' && id)
|
|
1160
|
+
: [];
|
|
1161
|
+
premiumPick = picks[0] || null;
|
|
1162
|
+
} catch { /* fresh install — no router file yet */ }
|
|
1163
|
+
if (premiumPick) {
|
|
1164
|
+
console.log(chalk.dim(` Premium model: ANTHROPIC_MODEL=${premiumPick} claude`));
|
|
1165
|
+
} else {
|
|
1166
|
+
console.log(chalk.dim(' Premium model: (no premium pick configured yet — run `bizar models`)'));
|
|
1167
|
+
}
|
|
1112
1168
|
console.log(chalk.dim(' See /use-premium or .claude/commands/use-premium.md for the full launch snippet.'));
|
|
1113
1169
|
console.log('');
|
|
1114
1170
|
return { ok: !anyFail, mode: effectiveMode, state: detectState(), stepResults };
|
|
@@ -31,11 +31,13 @@ Default tier classification heuristic (set by the picker, overridable per-model
|
|
|
31
31
|
| anything else | `mid` |
|
|
32
32
|
| `nano`, `mini`, `haiku` (older), `flash`, `lite`, `tiny` | `budget` |
|
|
33
33
|
|
|
34
|
-
Concrete example: if `userSelected.models = ["
|
|
35
|
-
- a `default`-tier dispatch picks
|
|
36
|
-
- a `premium`-tier dispatch picks
|
|
34
|
+
Concrete example: if `userSelected.models = ["<pick-from-default-tier>", "<pick-from-premium-tier>"]` and `tierHints = { "<pick-from-default-tier>": "default", "<pick-from-premium-tier>": "premium" }`:
|
|
35
|
+
- a `default`-tier dispatch picks `<pick-from-default-tier>`,
|
|
36
|
+
- a `premium`-tier dispatch picks `<pick-from-premium-tier>`,
|
|
37
37
|
- anything else (no tier configured) → omit `model` and inherit the session.
|
|
38
38
|
|
|
39
|
+
10.22.0 / Phase 4: agent docs ship with placeholder ids (`<pick-from-default-tier>`, `<pick-from-premium-tier>`) instead of literal model ids so a provider swap never requires editing this file. Operators configure the real ids via `bizar models`.
|
|
40
|
+
|
|
39
41
|
The SDK resolver (`packages/sdk/src/router/agent-model-registry.ts#rankUserSelectedForRole`) now ranks the `userSelected` pool by capability profile before falling back to the tier default.
|
|
40
42
|
|
|
41
43
|
Do not auto-discover new models. Do not add tier-candidates that are not in `userSelected`. The Agent-model-guard (`config/claude/hooks/agent-model-guard.mjs`) blocks any other model override.
|
|
@@ -60,18 +60,62 @@ function advise(reason) {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 10.22.0 / Phase 4: extract the operator's `disabledProviders` list from
|
|
65
|
+
* the loaded registry. Whitespace-trimmed + lowercased at read time.
|
|
66
|
+
* Returns `[]` for legacy configs that lack the key (no in-code default).
|
|
67
|
+
* The hook MUST fail open on parse errors — the orchestrator already
|
|
68
|
+
* chose a model; let Claude Code validate it once.
|
|
69
|
+
*/
|
|
70
|
+
function readDisabledProvidersFromRegistry(registry) {
|
|
71
|
+
if (!registry || typeof registry !== 'object') return [];
|
|
72
|
+
const raw = registry.disabledProviders;
|
|
73
|
+
if (!Array.isArray(raw)) return [];
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const v of raw) {
|
|
76
|
+
if (typeof v !== 'string') continue;
|
|
77
|
+
const trimmed = v.trim();
|
|
78
|
+
if (!trimmed) continue;
|
|
79
|
+
out.push(trimmed.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 10.22.0 / Phase 4: case-sensitive prefix filter against the (lowercase)
|
|
86
|
+
* disabled list. Empty / missing prefix list is a no-op (returns input).
|
|
87
|
+
*/
|
|
88
|
+
function isDisabledId(id, prefixes) {
|
|
89
|
+
if (!Array.isArray(prefixes) || prefixes.length === 0) return false;
|
|
90
|
+
if (typeof id !== 'string' || !id) return false;
|
|
91
|
+
for (const p of prefixes) {
|
|
92
|
+
if (typeof p === 'string' && p && id.startsWith(p)) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
63
97
|
/**
|
|
64
98
|
* Models that pass the configured-tier check: every model in any
|
|
65
99
|
* `tiers.<x>.models` block, PLUS every model in `userSelected.models`.
|
|
100
|
+
*
|
|
101
|
+
* 10.22.0 / Phase 4: ids whose provider prefix is on the operator's
|
|
102
|
+
* `disabledProviders` list are silently filtered out — the picker IS
|
|
103
|
+
* still the discovery surface for user picks, but the operator's
|
|
104
|
+
* disable intent overrides user intent.
|
|
66
105
|
*/
|
|
67
106
|
function configuredModels(registry) {
|
|
107
|
+
const disabled = readDisabledProvidersFromRegistry(registry);
|
|
68
108
|
const out = new Set();
|
|
69
109
|
for (const tier of Object.values(registry?.tiers || {})) {
|
|
70
|
-
if (Array.isArray(tier?.models)) for (const id of tier.models)
|
|
110
|
+
if (Array.isArray(tier?.models)) for (const id of tier.models) {
|
|
111
|
+
if (!isDisabledId(id, disabled)) out.add(id);
|
|
112
|
+
}
|
|
71
113
|
}
|
|
72
114
|
const userSelected = registry?.userSelected;
|
|
73
115
|
if (userSelected && Array.isArray(userSelected.models)) {
|
|
74
|
-
for (const id of userSelected.models)
|
|
116
|
+
for (const id of userSelected.models) {
|
|
117
|
+
if (!isDisabledId(id, disabled)) out.add(id);
|
|
118
|
+
}
|
|
75
119
|
}
|
|
76
120
|
return out;
|
|
77
121
|
}
|
|
@@ -79,12 +123,17 @@ function configuredModels(registry) {
|
|
|
79
123
|
/**
|
|
80
124
|
* Subset of `configuredModels` that came from the user picker. These bypass
|
|
81
125
|
* the live-discovery validation (the picker IS the discovery).
|
|
126
|
+
*
|
|
127
|
+
* 10.22.0 / Phase 4: same disabled-prefix filter as `configuredModels`.
|
|
82
128
|
*/
|
|
83
129
|
function userSelectedModels(registry) {
|
|
130
|
+
const disabled = readDisabledProvidersFromRegistry(registry);
|
|
84
131
|
const out = new Set();
|
|
85
132
|
const userSelected = registry?.userSelected;
|
|
86
133
|
if (userSelected && Array.isArray(userSelected.models)) {
|
|
87
|
-
for (const id of userSelected.models)
|
|
134
|
+
for (const id of userSelected.models) {
|
|
135
|
+
if (!isDisabledId(id, disabled)) out.add(id);
|
|
136
|
+
}
|
|
88
137
|
}
|
|
89
138
|
return out;
|
|
90
139
|
}
|
|
@@ -127,6 +176,16 @@ export async function guardAgentModel(input, options = {}) {
|
|
|
127
176
|
return {};
|
|
128
177
|
}
|
|
129
178
|
|
|
179
|
+
// 10.22.0 / Phase 4: silent-filter contract — if the operator's
|
|
180
|
+
// `disabledProviders` list covers the requested id, fall through
|
|
181
|
+
// without advising. The orchestrator already chose the id; the
|
|
182
|
+
// operator's disable intent overrides user picks at config time, not
|
|
183
|
+
// dispatch time. Pin: see
|
|
184
|
+
// `config/claude/hooks/__tests__/agent-model-guard.test.mjs#Agent
|
|
185
|
+
// model guard filters disabled-provider user picks silently`.
|
|
186
|
+
const disabled = readDisabledProvidersFromRegistry(registry);
|
|
187
|
+
if (isDisabledId(requested, disabled)) return {};
|
|
188
|
+
|
|
130
189
|
const allowed = configuredModels(registry);
|
|
131
190
|
const userPicks = userSelectedModels(registry);
|
|
132
191
|
|
|
@@ -65,6 +65,69 @@ function readRouterPath() {
|
|
|
65
65
|
return join(homedir(), '.config', 'bizar', 'config', 'claude', 'model-router.json');
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* 10.22.0 / Phase 4: read the operator's `disabledProviders: string[]`
|
|
70
|
+
* list with the same dual-path contract as `cli/commands/models.mjs`.
|
|
71
|
+
* The Bizar path (`~/.config/bizar/config/claude/model-router.json`) wins
|
|
72
|
+
* when both exist (even with explicit `[]`); the legacy
|
|
73
|
+
* `~/.claude/model-router.json` mirror is the fallback. Whitespace +
|
|
74
|
+
* lowercase normalization happens here. Returns `[]` on any failure —
|
|
75
|
+
* the hook is advisory and must NEVER block session start.
|
|
76
|
+
*/
|
|
77
|
+
function readDisabledProviders() {
|
|
78
|
+
const bizarPath = readRouterPath();
|
|
79
|
+
const legacyPath = join(homedir(), '.claude', 'model-router.json');
|
|
80
|
+
const extract = (parsed) => {
|
|
81
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
82
|
+
if (!Array.isArray(parsed.disabledProviders)) return null;
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const v of parsed.disabledProviders) {
|
|
85
|
+
if (typeof v !== 'string') continue;
|
|
86
|
+
const trimmed = v.trim();
|
|
87
|
+
if (!trimmed) continue;
|
|
88
|
+
out.push(trimmed.toLowerCase());
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
if (existsSync(bizarPath)) {
|
|
93
|
+
const parsed = readJsonIfObject(bizarPath);
|
|
94
|
+
// Bizar path exists — its `disabledProviders` is authoritative even
|
|
95
|
+
// when explicit `[]`. Missing key still falls back to the legacy.
|
|
96
|
+
if (parsed && Array.isArray(parsed.disabledProviders)) {
|
|
97
|
+
const extracted = extract(parsed);
|
|
98
|
+
if (extracted !== null) return extracted;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const legacy = readJsonIfObject(legacyPath);
|
|
102
|
+
if (legacy && Array.isArray(legacy.disabledProviders)) {
|
|
103
|
+
const extracted = extract(legacy);
|
|
104
|
+
if (extracted !== null) return extracted;
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Strip disabled-provider prefixes from an id list. Case-sensitive
|
|
111
|
+
* prefix match against the (lowercase) disabled list. The hook mirrors
|
|
112
|
+
* the CLI's `filterCandidatesByDisabledProviders` contract — see
|
|
113
|
+
* `cli/commands/models.mjs` for the authoritative implementation.
|
|
114
|
+
*/
|
|
115
|
+
function filterDisabled(ids, disabled) {
|
|
116
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
117
|
+
const prefixes = Array.isArray(disabled) ? disabled.filter((p) => typeof p === 'string' && p) : [];
|
|
118
|
+
if (prefixes.length === 0) return [...list];
|
|
119
|
+
const kept = [];
|
|
120
|
+
for (const id of list) {
|
|
121
|
+
if (typeof id !== 'string' || !id) { kept.push(id); continue; }
|
|
122
|
+
let blocked = false;
|
|
123
|
+
for (const p of prefixes) {
|
|
124
|
+
if (id.startsWith(p)) { blocked = true; break; }
|
|
125
|
+
}
|
|
126
|
+
if (!blocked) kept.push(id);
|
|
127
|
+
}
|
|
128
|
+
return kept;
|
|
129
|
+
}
|
|
130
|
+
|
|
68
131
|
function readSettingsPath() {
|
|
69
132
|
return join(homedir(), '.claude', 'settings.json');
|
|
70
133
|
}
|
|
@@ -129,7 +192,15 @@ function syncOnce() {
|
|
|
129
192
|
const profiles = userSelected && userSelected.profiles && typeof userSelected.profiles === 'object'
|
|
130
193
|
? userSelected.profiles
|
|
131
194
|
: {};
|
|
132
|
-
|
|
195
|
+
// 10.22.0 / Phase 4: filter the operator's disabled-provider ids out
|
|
196
|
+
// of the SessionStart re-apply so Claude Code's `/model` picker never
|
|
197
|
+
// surfaces e.g. `anthropic/*` after a session restart. Dual-path read
|
|
198
|
+
// matches `cli/commands/models.mjs#readDisabledProviders`.
|
|
199
|
+
const disabled = readDisabledProviders();
|
|
200
|
+
const liveIds = filterDisabled(
|
|
201
|
+
models.filter((id) => typeof id === 'string' && id.trim()),
|
|
202
|
+
disabled,
|
|
203
|
+
);
|
|
133
204
|
if (liveIds.length === 0) {
|
|
134
205
|
return { applied: 0, modelAfter: null, skipped: 'no-userSelected' };
|
|
135
206
|
}
|
|
@@ -155,7 +226,7 @@ function syncOnce() {
|
|
|
155
226
|
settings.modelOverrides = Object.fromEntries(liveIds.map((id) => [id, id]));
|
|
156
227
|
|
|
157
228
|
let modelChanged = false;
|
|
158
|
-
if (typeof settings.model === 'string' && settings.model
|
|
229
|
+
if (typeof settings.model === 'string' && !liveIds.includes(settings.model)) {
|
|
159
230
|
settings.model = liveIds[0];
|
|
160
231
|
modelChanged = true;
|
|
161
232
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"$schema": "https://bizar.dev/schema/model-router.v3.json",
|
|
3
3
|
"version": "13.0.0",
|
|
4
4
|
"endpoint": null,
|
|
5
|
-
"
|
|
5
|
+
"disabledProviders": ["anthropic"],
|
|
6
|
+
"comment": "Mike selects the cheapest sufficient user-selected model per dispatch. Agent roles never carry fixed models. The orchestrator dispatches ONLY with IDs in `userSelected.models`. When `userSelected` is empty, omit the Agent model override and inherit the active session model instead of retrying aliases. The gateway endpoint is intentionally null in the shipped config — operators MUST configure it via the `ANTHROPIC_BASE_URL` or `BIZAR_MODEL_ROUTER_URL` environment variable. Bizar is provider-agnostic and ships no default provider. `disabledProviders` (top-level, optional) is a case-insensitive-at-write, case-sensitive-at-filter list of provider prefixes whose model ids are stripped from every read site (CLI sync, picker, settings.json sync, SessionStart hook, Agent model guard). Defaults to `[]`; ships with `[\"anthropic\"]` so a fresh install never dispatches `anthropic/*` ids even if a gateway reports them.",
|
|
6
7
|
"userSelected": {
|
|
7
8
|
"models": [],
|
|
8
9
|
"lastUpdated": null,
|
|
@@ -59,10 +59,6 @@
|
|
|
59
59
|
"bgIsolation": "worktree",
|
|
60
60
|
"cleanupPeriodDays": 7
|
|
61
61
|
},
|
|
62
|
-
"model": "claude-minimax/MiniMax-M3[1m]",
|
|
63
|
-
"modelOverrides": {
|
|
64
|
-
"claude-minimax/MiniMax-M3": "claude-minimax/MiniMax-M3[1m]"
|
|
65
|
-
},
|
|
66
62
|
"alwaysThinkingEnabled": true,
|
|
67
63
|
"effortLevel": "high",
|
|
68
64
|
"skipDangerousModePermissionPrompt": true,
|
package/package.json
CHANGED