@polderlabs/bizar 10.23.9 → 10.23.11
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/models.mjs +9 -26
- package/cli/commands/tier.mjs +1 -1
- package/cli/commands/upgrade-defaults.mjs +3 -6
- package/cli/provision.mjs +31 -28
- package/config/agents/model-assignment.mjs +16 -9
- package/config/claude/agents/planner.md +2 -1
- package/config/claude/hooks/agent-model-guard.mjs +27 -4
- package/config/claude/hooks/sessionstart-model-sync.mjs +4 -15
- package/config/workflows/lib/dispatch.js +7 -5
- package/package.json +1 -2
- package/packages/sdk/dist/mcp/server.js +1 -1
- package/packages/sdk/dist/router/agent-model-registry.d.ts +7 -2
- package/packages/sdk/dist/router/agent-model-registry.js +38 -10
- package/packages/sdk/dist/router/failover-mirror.mjs +9 -5
- 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/config/claude/model-router.json +0 -110
package/cli/commands/models.mjs
CHANGED
|
@@ -716,9 +716,8 @@ export function classifyKind(modelId) {
|
|
|
716
716
|
// in-code hardcoded list — adding or removing a blocked provider is a
|
|
717
717
|
// single JSON edit.
|
|
718
718
|
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
// "no providers disabled" without deleting the legacy mirror). Whitespace
|
|
719
|
+
// Global-only read: the Bizar path (`~/.config/bizar/config/claude/model-router.json`)
|
|
720
|
+
// is the sole operator policy source. Whitespace
|
|
722
721
|
// trim + lowercase normalization happens at read time so operators may
|
|
723
722
|
// write `" Anthropic "` in JSON and still match `anthropic/...` model
|
|
724
723
|
// ids. The comparison itself is a case-sensitive prefix filter against
|
|
@@ -758,44 +757,28 @@ function extractDisabledProviders(router) {
|
|
|
758
757
|
|
|
759
758
|
/**
|
|
760
759
|
* Read the operator's `disabledProviders` list from the model-router
|
|
761
|
-
* config.
|
|
762
|
-
*
|
|
763
|
-
* is the fallback. Both paths are normalized (trim + lowercase) at read.
|
|
760
|
+
* config. The Bizar global path is the only source. It is normalized (trim +
|
|
761
|
+
* lowercase) at read.
|
|
764
762
|
*
|
|
765
763
|
* Pure function over the filesystem; returns an empty array when neither
|
|
766
|
-
* file exists,
|
|
764
|
+
* file exists, lacks the key, or cannot be read.
|
|
767
765
|
*
|
|
768
|
-
* @param {{ routerPath?: string
|
|
766
|
+
* @param {{ routerPath?: string }} [opts]
|
|
769
767
|
* - `routerPath` defaults to the Bizar home path
|
|
770
768
|
* (`~/.config/bizar/config/claude/model-router.json`).
|
|
771
|
-
* - `legacyPath` defaults to the Claude Code mirror
|
|
772
|
-
* (`~/.claude/model-router.json`).
|
|
773
769
|
* @returns {string[]} normalized disabled-provider prefixes
|
|
774
770
|
*/
|
|
775
|
-
export function readDisabledProviders({ routerPath
|
|
771
|
+
export function readDisabledProviders({ routerPath } = {}) {
|
|
776
772
|
const bizarPath = routerPath || resolveGlobalModelRouter();
|
|
777
|
-
const fallPath = legacyPath || join(resolveClaudeConfigDir(), 'model-router.json');
|
|
778
773
|
if (existsSync(bizarPath)) {
|
|
779
774
|
try {
|
|
780
775
|
const parsed = JSON.parse(readFileSync(bizarPath, 'utf8'));
|
|
781
776
|
const extracted = extractDisabledProviders(parsed);
|
|
782
|
-
//
|
|
783
|
-
// when explicitly `[]` (operators may pin "no providers disabled"
|
|
784
|
-
// without deleting the legacy mirror). Missing key still falls back.
|
|
777
|
+
// An explicit empty list is a valid global opt-out policy.
|
|
785
778
|
if (Array.isArray(parsed && typeof parsed === 'object' ? parsed.disabledProviders : undefined)) {
|
|
786
779
|
return extracted;
|
|
787
780
|
}
|
|
788
|
-
} catch {
|
|
789
|
-
// Corrupt Bizar file — fall through to the legacy mirror.
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
if (existsSync(fallPath)) {
|
|
793
|
-
try {
|
|
794
|
-
const parsed = JSON.parse(readFileSync(fallPath, 'utf8'));
|
|
795
|
-
return extractDisabledProviders(parsed);
|
|
796
|
-
} catch {
|
|
797
|
-
return [];
|
|
798
|
-
}
|
|
781
|
+
} catch { return []; }
|
|
799
782
|
}
|
|
800
783
|
return [];
|
|
801
784
|
}
|
package/cli/commands/tier.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `bizar tier` — automatic task-based model selection.
|
|
6
6
|
*
|
|
7
7
|
* Replays the same tier resolution the @mike orchestrator uses:
|
|
8
|
-
* 1. Read the role default from
|
|
8
|
+
* 1. Read the role default from the global Bizar model router.
|
|
9
9
|
* 2. Adjust the tier from current task risk and complexity.
|
|
10
10
|
* 3. Print ordered candidates; dispatch uses a candidate only when live
|
|
11
11
|
* discovery proves it, otherwise Agent omits model and inherits the session.
|
|
@@ -42,13 +42,11 @@ const CLAUDE_DIR = resolveClaudeConfigDir();
|
|
|
42
42
|
const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
|
|
43
43
|
// 10.22.0 / Phase 4 spirit-of-constraint: the install model id comes
|
|
44
44
|
// from the operator's `userSelected.models[0]`, not a hardcoded literal.
|
|
45
|
-
//
|
|
46
|
-
// the Bizar path wins when present; the Claude Code mirror is fallback.
|
|
45
|
+
// The Bizar global router is the sole model-policy source.
|
|
47
46
|
const BIZAR_ROUTER_PATH = resolveGlobalModelRouter();
|
|
48
|
-
const LEGACY_ROUTER_PATH = join(CLAUDE_DIR, 'model-router.json');
|
|
49
47
|
|
|
50
48
|
function readConfiguredModels() {
|
|
51
|
-
for (const path of [BIZAR_ROUTER_PATH
|
|
49
|
+
for (const path of [BIZAR_ROUTER_PATH]) {
|
|
52
50
|
if (!existsSync(path)) continue;
|
|
53
51
|
try {
|
|
54
52
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -58,8 +56,7 @@ function readConfiguredModels() {
|
|
|
58
56
|
if (list.length > 0) return list;
|
|
59
57
|
const fallback = configuredFallbackModels(parsed);
|
|
60
58
|
if (fallback.length > 0) return fallback;
|
|
61
|
-
//
|
|
62
|
-
// honour that intent (do NOT fall through to the legacy mirror).
|
|
59
|
+
// An explicit empty selection is an intentional global policy.
|
|
63
60
|
if (path === BIZAR_ROUTER_PATH && parsed && typeof parsed === 'object'
|
|
64
61
|
&& Array.isArray(parsed.userSelected?.models)) {
|
|
65
62
|
return [];
|
package/cli/provision.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
import { homedir } from 'node:os';
|
|
32
32
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
|
-
import { resolveBizarHome } from './config-paths.mjs';
|
|
34
|
+
import { resolveBizarHome, resolveGlobalModelRouter } from './config-paths.mjs';
|
|
35
35
|
import {
|
|
36
36
|
buildClaudeModelOverrides,
|
|
37
37
|
configuredEnabledModels,
|
|
@@ -465,9 +465,20 @@ export async function syncAgentFiles({ dryRun = false, force = false } = {}) {
|
|
|
465
465
|
return { ok: true, message: `${copied} agent(s) synced (${skipped} kept)${sharedCopied ? `, ${sharedCopied} _shared/*.md` : ''}${tail}`, copied, skipped, pruned };
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
-
// The dynamic model router
|
|
469
|
-
//
|
|
470
|
-
|
|
468
|
+
// The dynamic model router is operator-owned state. It is created only under
|
|
469
|
+
// Bizar's global config root; a repository can never supply a runtime model.
|
|
470
|
+
|
|
471
|
+
export const EMPTY_GLOBAL_MODEL_ROUTER = {
|
|
472
|
+
$schema: 'https://bizar.dev/schema/model-router.v3.json',
|
|
473
|
+
version: '13.0.0',
|
|
474
|
+
endpoint: null,
|
|
475
|
+
disabledProviders: ['anthropic'],
|
|
476
|
+
userSelected: { models: [], lastUpdated: null, source: 'default-empty', tierHints: {} },
|
|
477
|
+
gateway: { required: false, endpoint: null, availabilityProbe: '/models', discoveryTimeoutMs: 3000, unavailableBehavior: 'configured-tier-fallback' },
|
|
478
|
+
tiers: Object.fromEntries(['premium', 'high', 'mid-design', 'default', 'mid', 'budget'].map((name) => [name, { models: [] }])),
|
|
479
|
+
roleDefaults: { mike: 'premium', paul: 'premium', carl: 'premium', karen: 'high', linda: 'high', ria: 'mid-design', greg: 'default', steve: 'default', oscar: 'mid', todd: 'mid', susan: 'mid', pam: 'budget', brenda: 'budget', janet: 'budget', kevin: 'budget', brad: 'mid-design' },
|
|
480
|
+
policies: { mainOrchestrator: 'mike', selectionOwner: 'orchestrator', dispatchModelOverride: 'required-configured-candidate', unknownAgent: 'configured-tier-fallback', discoveryFailure: 'configured-tier-fallback', unavailableModel: 'configured-tier-fallback', silentProviderSubstitution: false, retryModelAliases: false, snapshotDecisions: true, maxDispatchModelAttempts: 1 },
|
|
481
|
+
};
|
|
471
482
|
|
|
472
483
|
export function isBizarManagedModelRouter(value) {
|
|
473
484
|
return Boolean(
|
|
@@ -479,16 +490,14 @@ export function isBizarManagedModelRouter(value) {
|
|
|
479
490
|
}
|
|
480
491
|
|
|
481
492
|
export async function syncModelRouter({ dryRun = false, force = false } = {}) {
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (existsSync(dest) && !force && !isBizarManagedModelRouter(readJsonSafe(dest, null))) {
|
|
486
|
-
return { ok: true, message: `${dest} is user-managed — pass --force to overwrite`, preserved: true };
|
|
493
|
+
const dest = resolveGlobalModelRouter();
|
|
494
|
+
if (existsSync(dest)) {
|
|
495
|
+
return { ok: true, message: `${dest} is operator-managed — preserved`, preserved: true };
|
|
487
496
|
}
|
|
488
|
-
if (dryRun) return { ok: true, message: `[dry-run] would
|
|
489
|
-
ensureDir(
|
|
490
|
-
|
|
491
|
-
return { ok: true, message: `model-router.json → ${dest}`, path: dest };
|
|
497
|
+
if (dryRun) return { ok: true, message: `[dry-run] would create global model router at ${dest}` };
|
|
498
|
+
ensureDir(dirname(dest));
|
|
499
|
+
writeFileSync(dest, `${JSON.stringify(EMPTY_GLOBAL_MODEL_ROUTER, null, 2)}\n`);
|
|
500
|
+
return { ok: true, message: `global model-router.json → ${dest}`, path: dest };
|
|
492
501
|
}
|
|
493
502
|
|
|
494
503
|
export async function syncSkillFiles({ dryRun = false, force = false } = {}) {
|
|
@@ -949,16 +958,12 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
|
|
|
949
958
|
// wins; otherwise the first enabled configured tier candidate is used.
|
|
950
959
|
// This prevents Claude Code from falling through to an unconfigured
|
|
951
960
|
// provider default when `userSelected.models` is intentionally empty.
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
// `cli/commands/upgrade-defaults.mjs#readUserSelectedModels`.
|
|
955
|
-
const bizarRouterPath = process.env.BIZAR_MODEL_ROUTER_CONFIG?.trim()
|
|
956
|
-
|| join(BIZAR_HOME(), 'config', 'claude', 'model-router.json');
|
|
957
|
-
const legacyRouterPath = join(CLAUDE_DIR, 'model-router.json');
|
|
961
|
+
// The Bizar global router is the sole source of model policy and picks.
|
|
962
|
+
const bizarRouterPath = resolveGlobalModelRouter();
|
|
958
963
|
let installModel;
|
|
959
964
|
let installContextTokens;
|
|
960
965
|
let installModels = [];
|
|
961
|
-
for (const p of [bizarRouterPath
|
|
966
|
+
for (const p of [bizarRouterPath]) {
|
|
962
967
|
if (!existsSync(p)) continue;
|
|
963
968
|
const parsed = readJsonSafe(p, null);
|
|
964
969
|
if (!parsed || typeof parsed !== 'object') continue;
|
|
@@ -966,8 +971,6 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
|
|
|
966
971
|
installModels = configuredEnabledModels(parsed);
|
|
967
972
|
installContextTokens = installModel ? configuredModelContextTokens(parsed, installModel) : undefined;
|
|
968
973
|
if (installModel) break;
|
|
969
|
-
// The Bizar path is authoritative even when it has no enabled candidates.
|
|
970
|
-
if (p === bizarRouterPath) break;
|
|
971
974
|
}
|
|
972
975
|
if (installModel) {
|
|
973
976
|
bizarSettings.model = installModel;
|
|
@@ -1177,16 +1180,16 @@ export async function runProvision(opts = {}) {
|
|
|
1177
1180
|
await runStep('Installing git hooks', () => installGitHooks({ dryRun }));
|
|
1178
1181
|
await runStep('Building SDK', () => buildSdk({ dryRun }));
|
|
1179
1182
|
|
|
1183
|
+
section('Ensuring global model-router.json');
|
|
1184
|
+
const routerStep = await syncModelRouter({ dryRun, force });
|
|
1185
|
+
if (routerStep.ok) logOk(routerStep.message); else logErr(routerStep.message);
|
|
1186
|
+
stepResults.push({ label: 'model-router', ...routerStep });
|
|
1187
|
+
|
|
1180
1188
|
section('Writing settings.json');
|
|
1181
1189
|
const settingsStep = writeClaudeSettings({ dryRun, force });
|
|
1182
1190
|
if (settingsStep.ok) logOk(settingsStep.message); else logErr(settingsStep.message);
|
|
1183
1191
|
stepResults.push({ label: 'settings.json', ...settingsStep });
|
|
1184
1192
|
|
|
1185
|
-
section('Syncing model-router.json');
|
|
1186
|
-
const routerStep = await syncModelRouter({ dryRun, force });
|
|
1187
|
-
if (routerStep.ok) logOk(routerStep.message); else logErr(routerStep.message);
|
|
1188
|
-
stepResults.push({ label: 'model-router', ...routerStep });
|
|
1189
|
-
|
|
1190
1193
|
section('MCP server (bizar)');
|
|
1191
1194
|
const mcpStep = setupMcpServer({ dryRun });
|
|
1192
1195
|
if (mcpStep.ok) logOk(mcpStep.message); else logWarn(mcpStep.message);
|
|
@@ -1221,7 +1224,7 @@ export async function runProvision(opts = {}) {
|
|
|
1221
1224
|
// back to a clear hint to run `bizar models`.
|
|
1222
1225
|
let premiumPick = null;
|
|
1223
1226
|
try {
|
|
1224
|
-
const routerPath =
|
|
1227
|
+
const routerPath = resolveGlobalModelRouter();
|
|
1225
1228
|
const router = JSON.parse(readFileSync(routerPath, 'utf8'));
|
|
1226
1229
|
const picks = Array.isArray(router?.userSelected?.tierHints?.premium)
|
|
1227
1230
|
? router.userSelected.tierHints.premium.filter((id) => typeof id === 'string' && id)
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
|
-
import {
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { resolveGlobalModelRouter } from '../../cli/config-paths.mjs';
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
export
|
|
13
|
+
/** Resolve the single operator-owned router at call time, never from a repo. */
|
|
14
|
+
export function defaultModelRouterPath() {
|
|
15
|
+
return resolveGlobalModelRouter();
|
|
16
|
+
}
|
|
16
17
|
|
|
17
18
|
function fail(code, message) {
|
|
18
19
|
const error = new Error(message);
|
|
@@ -39,9 +40,6 @@ function assertDynamicRegistry(registry) {
|
|
|
39
40
|
if (!registry.tiers || typeof registry.tiers !== 'object' || Object.keys(registry.tiers).length === 0) {
|
|
40
41
|
fail('MODEL_REGISTRY_INVALID', 'The model router must define at least one dynamic tier.');
|
|
41
42
|
}
|
|
42
|
-
for (const [name, tier] of Object.entries(registry.tiers)) {
|
|
43
|
-
if (normalizeModelIds(tier?.models).length === 0) fail('MODEL_REGISTRY_INVALID', `Tier ${name} has no candidate models.`);
|
|
44
|
-
}
|
|
45
43
|
const policies = registry.policies || {};
|
|
46
44
|
if (policies.selectionOwner !== 'orchestrator') fail('MODEL_POLICY_INVALID', 'The orchestrator must own model selection.');
|
|
47
45
|
if (!['configured-tier-fallback', 'inherit-session'].includes(policies.discoveryFailure)
|
|
@@ -54,7 +52,7 @@ function assertDynamicRegistry(registry) {
|
|
|
54
52
|
return registry;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
export function loadModelRouter(path =
|
|
55
|
+
export function loadModelRouter(path = defaultModelRouterPath()) {
|
|
58
56
|
try {
|
|
59
57
|
return assertDynamicRegistry(JSON.parse(readFileSync(path, 'utf8')));
|
|
60
58
|
} catch (error) {
|
|
@@ -73,7 +71,16 @@ export function resolveDispatchModel({
|
|
|
73
71
|
const definition = registry.tiers?.[chosenTier];
|
|
74
72
|
if (!definition) fail('UNKNOWN_TIER', `Unknown model tier ${chosenTier}.`);
|
|
75
73
|
const disabled = new Set(normalizeModelIds(registry.disabledProviders).map((prefix) => prefix.toLowerCase()));
|
|
76
|
-
const
|
|
74
|
+
const selected = normalizeModelIds(registry.userSelected?.models)
|
|
75
|
+
.filter((candidate) => ![...disabled].some((prefix) => candidate.toLowerCase().startsWith(prefix)));
|
|
76
|
+
const hints = registry.userSelected?.tierHints && typeof registry.userSelected.tierHints === 'object'
|
|
77
|
+
? registry.userSelected.tierHints
|
|
78
|
+
: {};
|
|
79
|
+
// The operator's picker is authoritative. A matching tier hint narrows the
|
|
80
|
+
// pool; otherwise all enabled picks remain eligible rather than silently
|
|
81
|
+
// falling back to repository-supplied candidates.
|
|
82
|
+
const hinted = selected.filter((candidate) => hints[candidate] === chosenTier);
|
|
83
|
+
const candidates = (hinted.length > 0 ? hinted : selected.length > 0 ? selected : normalizeModelIds(definition.models))
|
|
77
84
|
.filter((candidate) => ![...disabled].some((prefix) => candidate.toLowerCase().startsWith(prefix)));
|
|
78
85
|
if (candidates.length === 0) fail('NO_ENABLED_TIER_MODEL', `Tier ${chosenTier} has no enabled candidate models.`);
|
|
79
86
|
const available = availableModelIds == null ? null : new Set(normalizeModelIds(availableModelIds));
|
|
@@ -43,7 +43,8 @@ Every plan you produce MUST follow this shape. The user (or Mike, or any downstr
|
|
|
43
43
|
|
|
44
44
|
## Subagent Model Selection
|
|
45
45
|
|
|
46
|
-
When you delegate implementation, you recommend the model tier. Read
|
|
46
|
+
When you delegate implementation, you recommend the model tier. Read the
|
|
47
|
+
operator-global Bizar router (`$BIZAR_HOME/config/claude/model-router.json`) to confirm.
|
|
47
48
|
|
|
48
49
|
| Task shape | Route to | Tier |
|
|
49
50
|
|---|---|---|
|
|
@@ -44,9 +44,11 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { readFileSync } from 'node:fs';
|
|
47
|
+
import { join } from 'node:path';
|
|
47
48
|
import { pathToFileURL } from 'node:url';
|
|
48
49
|
|
|
49
50
|
import { loadModelRouter } from '../../../config/agents/model-assignment.mjs';
|
|
51
|
+
import { resolveClaudeConfigDir } from '../../../cli/config-paths.mjs';
|
|
50
52
|
|
|
51
53
|
function deny(reason) {
|
|
52
54
|
return {
|
|
@@ -152,6 +154,17 @@ function readFailoverBlock(toolInput) {
|
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
function readConfiguredParentModel(options = {}) {
|
|
158
|
+
if (typeof options.parentModel === 'string') return options.parentModel.trim();
|
|
159
|
+
const settingsPath = options.settingsPath || join(resolveClaudeConfigDir(), 'settings.json');
|
|
160
|
+
try {
|
|
161
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
162
|
+
return typeof settings?.model === 'string' ? settings.model.trim() : '';
|
|
163
|
+
} catch {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
export async function guardAgentModel(input, options = {}) {
|
|
156
169
|
if (!input || typeof input !== 'object') return {};
|
|
157
170
|
if (input.hook_event_name !== 'PreToolUse' || input.tool_name !== 'Agent') return {};
|
|
@@ -160,10 +173,6 @@ export async function guardAgentModel(input, options = {}) {
|
|
|
160
173
|
const failoverBlock = readFailoverBlock(toolInput);
|
|
161
174
|
const hasFailoverContract = Boolean(failoverBlock.routingDecisionId) && Boolean(failoverBlock.fallback);
|
|
162
175
|
|
|
163
|
-
if (!requested || requested === 'inherit') {
|
|
164
|
-
return deny('Bizar Agent dispatch blocked: every Agent call requires an explicit enabled model from `bizar models`; session/provider inheritance is prohibited.');
|
|
165
|
-
}
|
|
166
|
-
|
|
167
176
|
let registry;
|
|
168
177
|
try {
|
|
169
178
|
registry = options.registry || loadModelRouter(options.routerPath);
|
|
@@ -181,6 +190,20 @@ export async function guardAgentModel(input, options = {}) {
|
|
|
181
190
|
const allowed = configuredModels(registry);
|
|
182
191
|
const userPicks = userSelectedModels(registry);
|
|
183
192
|
|
|
193
|
+
// The native Agent tool rejects arbitrary gateway IDs in its `model` enum.
|
|
194
|
+
// Inheritance is safe only when the auditable Bizar selection equals the
|
|
195
|
+
// actual global Claude parent model and is a selected, enabled user pick.
|
|
196
|
+
if (!requested || requested === 'inherit') {
|
|
197
|
+
const configured = typeof toolInput.additionalContext?.bizarConfiguredModel === 'string'
|
|
198
|
+
? toolInput.additionalContext.bizarConfiguredModel.trim()
|
|
199
|
+
: '';
|
|
200
|
+
const parent = readConfiguredParentModel(options);
|
|
201
|
+
if (!configured || configured !== parent || !userPicks.has(configured)) {
|
|
202
|
+
return deny('Bizar Agent dispatch blocked: native Agent inheritance requires the global Claude parent model to exactly match an enabled Bizar user selection. Run `bizar models` and restart Claude Code.');
|
|
203
|
+
}
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
|
|
184
207
|
// F-185 contract: when the orchestrator passes both `routingDecisionId`
|
|
185
208
|
// and `fallback`, validate the fallback against the userSelected pool
|
|
186
209
|
// but skip the live-discovery re-probe. The fallback's eligibility was
|
|
@@ -61,16 +61,12 @@ function readRouterPath() {
|
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* 10.22.0 / Phase 4: read the operator's `disabledProviders: string[]`
|
|
64
|
-
* list
|
|
65
|
-
*
|
|
66
|
-
* when both exist (even with explicit `[]`); the legacy
|
|
67
|
-
* `~/.claude/model-router.json` mirror is the fallback. Whitespace +
|
|
68
|
-
* lowercase normalization happens here. Returns `[]` on any failure —
|
|
64
|
+
* list from the sole global Bizar router. Whitespace + lowercase
|
|
65
|
+
* normalization happens here. Returns `[]` on any failure —
|
|
69
66
|
* the hook is advisory and must NEVER block session start.
|
|
70
67
|
*/
|
|
71
68
|
function readDisabledProviders() {
|
|
72
69
|
const bizarPath = readRouterPath();
|
|
73
|
-
const legacyPath = join(resolveClaudeConfigDir(), 'model-router.json');
|
|
74
70
|
const extract = (parsed) => {
|
|
75
71
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
76
72
|
if (!Array.isArray(parsed.disabledProviders)) return null;
|
|
@@ -85,18 +81,12 @@ function readDisabledProviders() {
|
|
|
85
81
|
};
|
|
86
82
|
if (existsSync(bizarPath)) {
|
|
87
83
|
const parsed = readJsonIfObject(bizarPath);
|
|
88
|
-
//
|
|
89
|
-
// when explicit `[]`. Missing key still falls back to the legacy.
|
|
84
|
+
// An explicit empty global list is a valid operator policy.
|
|
90
85
|
if (parsed && Array.isArray(parsed.disabledProviders)) {
|
|
91
86
|
const extracted = extract(parsed);
|
|
92
87
|
if (extracted !== null) return extracted;
|
|
93
88
|
}
|
|
94
89
|
}
|
|
95
|
-
const legacy = readJsonIfObject(legacyPath);
|
|
96
|
-
if (legacy && Array.isArray(legacy.disabledProviders)) {
|
|
97
|
-
const extracted = extract(legacy);
|
|
98
|
-
if (extracted !== null) return extracted;
|
|
99
|
-
}
|
|
100
90
|
return [];
|
|
101
91
|
}
|
|
102
92
|
|
|
@@ -193,8 +183,7 @@ function syncOnce() {
|
|
|
193
183
|
: {};
|
|
194
184
|
// 10.22.0 / Phase 4: filter the operator's disabled-provider ids out
|
|
195
185
|
// of the SessionStart re-apply so Claude Code's `/model` picker never
|
|
196
|
-
// surfaces e.g. `anthropic/*` after a session restart.
|
|
197
|
-
// matches `cli/commands/models.mjs#readDisabledProviders`.
|
|
186
|
+
// surfaces e.g. `anthropic/*` after a session restart.
|
|
198
187
|
const disabled = readDisabledProviders();
|
|
199
188
|
const selectedIds = filterDisabled(
|
|
200
189
|
models.filter((id) => typeof id === 'string' && id.trim()),
|
|
@@ -793,15 +793,17 @@ export function classifyDispatchOutcome(result, error, startMs) {
|
|
|
793
793
|
}
|
|
794
794
|
|
|
795
795
|
/**
|
|
796
|
-
* Build the augmented agent-call payload.
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
* selector step fired.
|
|
796
|
+
* Build the augmented agent-call payload. The native Agent model field cannot
|
|
797
|
+
* carry arbitrary gateway IDs, so the selected Bizar ID is audit context and
|
|
798
|
+
* the guard permits inheritance only from an identical configured parent.
|
|
800
799
|
*/
|
|
801
800
|
export function augmentPayload(opts, decision, agentName) {
|
|
802
801
|
return {
|
|
803
802
|
...opts,
|
|
804
|
-
|
|
803
|
+
additionalContext: {
|
|
804
|
+
...(opts.additionalContext && typeof opts.additionalContext === 'object' ? opts.additionalContext : {}),
|
|
805
|
+
bizarConfiguredModel: decision.modelId ?? null,
|
|
806
|
+
},
|
|
805
807
|
routingDecisionId: decision.routingDecisionId,
|
|
806
808
|
tier: decision.tier,
|
|
807
809
|
selectorReason: decision.reason,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar",
|
|
3
|
-
"version": "10.23.
|
|
3
|
+
"version": "10.23.11",
|
|
4
4
|
"description": "Autonomous, human-in-the-loop multi-agent harness for Claude Code with guarded workflows, typed SDK primitives, and MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
"config/claude/hooks/*.mjs",
|
|
26
26
|
"config/claude/hooks/*.sh",
|
|
27
27
|
"config/claude/hooks/README.md",
|
|
28
|
-
"config/claude/model-router.json",
|
|
29
28
|
"config/claude/settings.json",
|
|
30
29
|
"scripts/git-hooks/commit-msg",
|
|
31
30
|
"scripts/git-hooks/pre-commit",
|
|
@@ -385,7 +385,7 @@ const bizarAuditTool = defineTool("bizar_audit", "Wrapper around `bizar audit --
|
|
|
385
385
|
}
|
|
386
386
|
}, { readOnlyHint: true });
|
|
387
387
|
// `bizar model list --json` — gateway model inventory.
|
|
388
|
-
const bizarModelListTool = defineTool("bizar_model_list", "Wrapper around `bizar models --json`. Returns ONLY the models the user has explicitly enabled via `bizar models`
|
|
388
|
+
const bizarModelListTool = defineTool("bizar_model_list", "Wrapper around `bizar models --json`. Returns ONLY the models the user has explicitly enabled via `bizar models` in the global Bizar router (`$BIZAR_HOME/config/claude/model-router.json#userSelected`). Live discovery is filtered out by default to avoid surfacing dozens of unrelated models. An empty list means no agent dispatch is permitted until the operator configures models.", {}, async () => {
|
|
389
389
|
try {
|
|
390
390
|
// Prefer the new `bizar models` surface; fall back to the legacy
|
|
391
391
|
// `bizar model list` alias for older installs. The CLI is responsible
|
|
@@ -225,8 +225,13 @@ export declare function rankUserSelectedForRole(registry: ModelRegistry, role: s
|
|
|
225
225
|
};
|
|
226
226
|
/**
|
|
227
227
|
* Pure sorter extracted for testability. Mutates and returns the input
|
|
228
|
-
* array. Sort key: `(eligible desc,
|
|
229
|
-
*
|
|
228
|
+
* array. Sort key: `(eligible desc, originalIndex asc, capabilityScore desc,
|
|
229
|
+
* hasProfile desc)`. The `originalIndex` primary sort key honours the
|
|
230
|
+
* operator's `userSelected.models` ordering as the authoritative pick
|
|
231
|
+
* sequence — the same contract as the CLI's `resolveDispatchModel`. The
|
|
232
|
+
* capability score is a secondary tiebreaker for entries that share the
|
|
233
|
+
* same `originalIndex` position (i.e., when a profile lookup changes
|
|
234
|
+
* eligibility status without reordering).
|
|
230
235
|
*/
|
|
231
236
|
export declare function compareRankedEntries(a: RankedUserSelectedEntry, b: RankedUserSelectedEntry): number;
|
|
232
237
|
/**
|
|
@@ -85,8 +85,6 @@ export function loadModelRegistry(src = {}) {
|
|
|
85
85
|
if (!isRecord(value))
|
|
86
86
|
registryError("CONFIG_INVALID", `Tier ${name} is invalid.`);
|
|
87
87
|
const ids = modelIds(value.models);
|
|
88
|
-
if (ids.length === 0)
|
|
89
|
-
registryError("CONFIG_INVALID", `Tier ${name} has no candidate models.`);
|
|
90
88
|
tiers.set(name, { tier: name, modelIds: ids, purpose: String(value.purpose || ""), effort: typeof value.effort === "string" ? value.effort : null });
|
|
91
89
|
}
|
|
92
90
|
const roleDefaults = new Map();
|
|
@@ -98,6 +96,25 @@ export function loadModelRegistry(src = {}) {
|
|
|
98
96
|
const configuredEndpoint = typeof raw.endpoint === "string" ? raw.endpoint : typeof gateway.endpoint === "string" ? gateway.endpoint : null;
|
|
99
97
|
const endpoint = process.env.BIZAR_MODEL_ROUTER_URL ?? process.env.ANTHROPIC_BASE_URL ?? configuredEndpoint;
|
|
100
98
|
const userSelected = parseUserSelected(raw.userSelected);
|
|
99
|
+
// Empty tier lists are tolerated when the operator's `userSelected`
|
|
100
|
+
// pool is non-empty: the runtime resolver (resolveTierModel) prefers
|
|
101
|
+
// the operator's authoritative picks and only falls through to a
|
|
102
|
+
// tier's static model list when no user pick matches. The CLI mirror
|
|
103
|
+
// (config/agents/model-assignment.mjs#resolveDispatchModel) applies
|
|
104
|
+
// the same rule; this keeps SDK and CLI symmetric on the canonical
|
|
105
|
+
// template, which ships empty tier lists by design (F-201 follow-up:
|
|
106
|
+
// remove every shipped provider/model default). When `userSelected`
|
|
107
|
+
// is also empty, every tier must still carry at least one candidate
|
|
108
|
+
// so the resolver never dispatches with no model at all.
|
|
109
|
+
if (!(userSelected && userSelected.models.length > 0)) {
|
|
110
|
+
for (const [name, value] of Object.entries(raw.tiers)) {
|
|
111
|
+
if (!isRecord(value))
|
|
112
|
+
registryError("CONFIG_INVALID", `Tier ${name} is invalid.`);
|
|
113
|
+
const ids = modelIds(value.models);
|
|
114
|
+
if (ids.length === 0)
|
|
115
|
+
registryError("CONFIG_INVALID", `Tier ${name} has no candidate models.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
101
118
|
return { version: String(raw.version || "unknown"), endpoint, configuredEndpoint, gateway, policies: raw.policies, tiers, roleDefaults, ...(userSelected ? { userSelected } : {}) };
|
|
102
119
|
}
|
|
103
120
|
function parseUserSelected(raw) {
|
|
@@ -298,24 +315,35 @@ export function rankUserSelectedForRole(registry, role, requirements = {}) {
|
|
|
298
315
|
return entry;
|
|
299
316
|
});
|
|
300
317
|
ranked.sort(compareRankedEntries);
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
|
|
318
|
+
// Tier-aware filtering (parity with the CLI's
|
|
319
|
+
// resolveDispatchModel#hinted): when the caller asks for a specific
|
|
320
|
+
// tier (the common dispatch path passes the agent's role-default
|
|
321
|
+
// tier), narrow `eligible` to entries whose tier hint matches. When
|
|
322
|
+
// no entry matches the requested tier, fall back to the full eligible
|
|
323
|
+
// ranking so a stale tierHints map never strands a dispatch with no
|
|
324
|
+
// candidates at all — same fail-open shape as the CLI.
|
|
325
|
+
const tierMatched = role ? ranked.filter((entry) => entry.eligible && entry.tier === role) : ranked.filter((entry) => entry.eligible);
|
|
326
|
+
const eligible = tierMatched.length > 0 ? tierMatched : ranked.filter((entry) => entry.eligible);
|
|
304
327
|
return { ranked, eligible };
|
|
305
328
|
}
|
|
306
329
|
/**
|
|
307
330
|
* Pure sorter extracted for testability. Mutates and returns the input
|
|
308
|
-
* array. Sort key: `(eligible desc,
|
|
309
|
-
*
|
|
331
|
+
* array. Sort key: `(eligible desc, originalIndex asc, capabilityScore desc,
|
|
332
|
+
* hasProfile desc)`. The `originalIndex` primary sort key honours the
|
|
333
|
+
* operator's `userSelected.models` ordering as the authoritative pick
|
|
334
|
+
* sequence — the same contract as the CLI's `resolveDispatchModel`. The
|
|
335
|
+
* capability score is a secondary tiebreaker for entries that share the
|
|
336
|
+
* same `originalIndex` position (i.e., when a profile lookup changes
|
|
337
|
+
* eligibility status without reordering).
|
|
310
338
|
*/
|
|
311
339
|
export function compareRankedEntries(a, b) {
|
|
312
340
|
if (a.eligible !== b.eligible)
|
|
313
341
|
return a.eligible ? -1 : 1;
|
|
342
|
+
if (a.originalIndex !== b.originalIndex)
|
|
343
|
+
return a.originalIndex - b.originalIndex;
|
|
314
344
|
if (a.capabilityScore !== b.capabilityScore)
|
|
315
345
|
return b.capabilityScore - a.capabilityScore;
|
|
316
|
-
|
|
317
|
-
return a.hasProfile ? -1 : 1;
|
|
318
|
-
return a.originalIndex - b.originalIndex;
|
|
346
|
+
return a.hasProfile !== b.hasProfile ? (a.hasProfile ? -1 : 1) : 0;
|
|
319
347
|
}
|
|
320
348
|
/**
|
|
321
349
|
* Weighted capability score: reasoning=0.3, toolCall=0.25,
|
|
@@ -175,13 +175,17 @@ export function rankUserSelectedForRole(registry, role, requirements = {}) {
|
|
|
175
175
|
});
|
|
176
176
|
ranked.sort((a, b) => {
|
|
177
177
|
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
|
|
178
|
+
if (a.originalIndex !== b.originalIndex) return a.originalIndex - b.originalIndex;
|
|
178
179
|
if (a.capabilityScore !== b.capabilityScore) return b.capabilityScore - a.capabilityScore;
|
|
179
|
-
|
|
180
|
-
return a.originalIndex - b.originalIndex;
|
|
180
|
+
return a.hasProfile !== b.hasProfile ? (a.hasProfile ? -1 : 1) : 0;
|
|
181
181
|
});
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
182
|
+
// Tier-aware filtering (parity with the CLI's
|
|
183
|
+
// resolveDispatchModel#hinted): narrow `eligible` to entries whose
|
|
184
|
+
// tier hint matches the requested tier; fall back to the full eligible
|
|
185
|
+
// ranking when no tier-matched candidate exists so a stale tierHints
|
|
186
|
+
// map never strands a dispatch with no candidates at all.
|
|
187
|
+
const tierMatched = role ? ranked.filter((entry) => entry.eligible && entry.tier === role) : ranked.filter((entry) => entry.eligible);
|
|
188
|
+
const eligible = tierMatched.length > 0 ? tierMatched : ranked.filter((entry) => entry.eligible);
|
|
185
189
|
return { ranked, eligible };
|
|
186
190
|
}
|
|
187
191
|
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://bizar.dev/schema/model-router.v3.json",
|
|
3
|
-
"version": "13.0.0",
|
|
4
|
-
"endpoint": null,
|
|
5
|
-
"disabledProviders": ["anthropic"],
|
|
6
|
-
"comment": "Mike selects the cheapest sufficient enabled operator-configured model per dispatch. Explicit user-selected models take precedence; empty shipped tiers ensure a fresh install never silently chooses a vendor or model. The orchestrator always supplies an Agent model and fails closed when no enabled configured candidate exists, so Claude Code cannot select an unconfigured provider default. 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 model. `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). Anthropic is opted out by default; operators may remove that entry or opt out of additional providers.",
|
|
7
|
-
"userSelected": {
|
|
8
|
-
"models": [],
|
|
9
|
-
"lastUpdated": null,
|
|
10
|
-
"source": "default-empty",
|
|
11
|
-
"tierHints": {}
|
|
12
|
-
},
|
|
13
|
-
"gateway": {
|
|
14
|
-
"required": false,
|
|
15
|
-
"endpoint": null,
|
|
16
|
-
"availabilityProbe": "/models",
|
|
17
|
-
"discoveryTimeoutMs": 3000,
|
|
18
|
-
"unavailableBehavior": "configured-tier-fallback",
|
|
19
|
-
"documentation": "https://code.claude.com/docs/en/llm-gateway"
|
|
20
|
-
},
|
|
21
|
-
"tiers": {
|
|
22
|
-
"premium": {
|
|
23
|
-
"models": [],
|
|
24
|
-
"purpose": "Architecture, high-risk planning, adversarial verification, and last-resort debugging where mistakes are expensive.",
|
|
25
|
-
"effort": "high"
|
|
26
|
-
},
|
|
27
|
-
"high": {
|
|
28
|
-
"models": [],
|
|
29
|
-
"purpose": "Complex implementation, security review, and cross-cutting reasoning.",
|
|
30
|
-
"effort": "high"
|
|
31
|
-
},
|
|
32
|
-
"mid-design": {
|
|
33
|
-
"models": [],
|
|
34
|
-
"purpose": "UI/UX design and product-quality implementation where visual judgment matters.",
|
|
35
|
-
"effort": "medium"
|
|
36
|
-
},
|
|
37
|
-
"default": {
|
|
38
|
-
"models": [],
|
|
39
|
-
"purpose": "Broad research, git operations, and ordinary engineering work.",
|
|
40
|
-
"effort": "medium"
|
|
41
|
-
},
|
|
42
|
-
"mid": {
|
|
43
|
-
"models": [],
|
|
44
|
-
"purpose": "Bounded technical analysis, code search, and moderate implementation.",
|
|
45
|
-
"effort": "medium"
|
|
46
|
-
},
|
|
47
|
-
"budget": {
|
|
48
|
-
"models": [],
|
|
49
|
-
"purpose": "Routine deterministic edits, targeted clarification, and scripted verification.",
|
|
50
|
-
"effort": "low"
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
"roleDefaults": {
|
|
54
|
-
"mike": "premium",
|
|
55
|
-
"paul": "premium",
|
|
56
|
-
"carl": "premium",
|
|
57
|
-
"karen": "high",
|
|
58
|
-
"linda": "high",
|
|
59
|
-
"ria": "mid-design",
|
|
60
|
-
"greg": "default",
|
|
61
|
-
"steve": "default",
|
|
62
|
-
"oscar": "mid",
|
|
63
|
-
"todd": "mid",
|
|
64
|
-
"susan": "mid",
|
|
65
|
-
"pam": "budget",
|
|
66
|
-
"brenda": "budget",
|
|
67
|
-
"janet": "budget",
|
|
68
|
-
"kevin": "budget",
|
|
69
|
-
"brad": "mid-design"
|
|
70
|
-
},
|
|
71
|
-
"complexityRules": [
|
|
72
|
-
{
|
|
73
|
-
"when": "irreversible, security-sensitive, architectural, or previously failed twice",
|
|
74
|
-
"tier": "premium"
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"when": "cross-cutting implementation or adversarial review",
|
|
78
|
-
"tier": "high"
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
"when": "UI or product design judgment dominates",
|
|
82
|
-
"tier": "mid-design"
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"when": "bounded implementation or codebase research",
|
|
86
|
-
"tier": "mid"
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
"when": "deterministic, mechanical, or clarification-only",
|
|
90
|
-
"tier": "budget"
|
|
91
|
-
}
|
|
92
|
-
],
|
|
93
|
-
"policies": {
|
|
94
|
-
"mainOrchestrator": "mike",
|
|
95
|
-
"selectionOwner": "orchestrator",
|
|
96
|
-
"dispatchModelOverride": "required-configured-candidate",
|
|
97
|
-
"unknownAgent": "configured-tier-fallback",
|
|
98
|
-
"discoveryFailure": "configured-tier-fallback",
|
|
99
|
-
"unavailableModel": "configured-tier-fallback",
|
|
100
|
-
"silentProviderSubstitution": false,
|
|
101
|
-
"retryModelAliases": false,
|
|
102
|
-
"snapshotDecisions": true,
|
|
103
|
-
"maxDispatchModelAttempts": 1
|
|
104
|
-
},
|
|
105
|
-
"costControls": {
|
|
106
|
-
"maxConcurrentAgents": 10,
|
|
107
|
-
"maxPremiumAgentsPerWorkflow": 3,
|
|
108
|
-
"preferInheritanceWhenDiscoveryUnavailable": false
|
|
109
|
-
}
|
|
110
|
-
}
|