@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.
- package/cli/commands/model.mjs +30 -11
- package/cli/commands/models.mjs +205 -20
- package/cli/commands/upgrade-defaults.mjs +44 -5
- package/cli/commands/workflow-gc.mjs +227 -0
- 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/config/workflows/bizar-debug.js +17 -5
- package/config/workflows/bizar-implement.js +22 -6
- package/config/workflows/bizar-research.js +33 -6
- package/config/workflows/lib/dispatch.js +426 -1
- package/config/workflows/ultracode-research.js +13 -3
- package/config/workflows/ultracode-review.js +7 -2
- package/config/workflows/ultracode.js +31 -6
- package/package.json +4 -2
- 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;
|