@polderlabs/bizar 10.17.0 → 10.17.1
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
CHANGED
|
@@ -24,7 +24,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
26
|
rankUserSelectedForRole as rankUserSelectedForRoleMirror,
|
|
27
|
-
} from '../../packages/sdk/
|
|
27
|
+
} from '../../packages/sdk/dist/router/failover-mirror.mjs';
|
|
28
28
|
|
|
29
29
|
// ── Endpoint resolution ──────────────────────────────────────────────────────
|
|
30
30
|
|
|
@@ -696,7 +696,7 @@ function explainRankedEntry(entry) {
|
|
|
696
696
|
* Pure function over `routerPath` — no stdout I/O. The `run` entry point
|
|
697
697
|
* owns the output and exit codes.
|
|
698
698
|
*
|
|
699
|
-
* The mirror lives at `packages/sdk/
|
|
699
|
+
* The mirror lives at `packages/sdk/dist/router/failover-mirror.mjs` and is
|
|
700
700
|
* byte-identical to the SDK's algorithm; if it diverges, the divergence
|
|
701
701
|
* test in `cli/__tests__/models-picker.test.mjs` fails.
|
|
702
702
|
*
|
|
@@ -721,7 +721,7 @@ export function explainSelection({ routerPath, role, requirements = {} } = {}) {
|
|
|
721
721
|
registry = { userSelected: undefined };
|
|
722
722
|
}
|
|
723
723
|
if (typeof rankUserSelectedForRoleMirror !== 'function') {
|
|
724
|
-
const err = new Error('bizar models explain requires packages/sdk/
|
|
724
|
+
const err = new Error('bizar models explain requires packages/sdk/dist/router/failover-mirror.mjs to be loadable');
|
|
725
725
|
err.code = 'SDK_UNAVAILABLE';
|
|
726
726
|
throw err;
|
|
727
727
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar",
|
|
3
|
-
"version": "10.17.
|
|
3
|
+
"version": "10.17.1",
|
|
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": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
|
-
"build:sdk": "node scripts/
|
|
38
|
+
"build:sdk": "node scripts/build-sdk.mjs",
|
|
39
39
|
"test:sdk": "node_modules/.bin/vitest run --root packages/sdk",
|
|
40
40
|
"test:sdk:watch": "node_modules/.bin/vitest --root packages/sdk",
|
|
41
41
|
"test:node": "node scripts/run-node-tests.mjs",
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router/failover-mirror.mjs
|
|
3
|
+
*
|
|
4
|
+
* Pure-JavaScript mirror of `packages/sdk/src/router/failover.ts` for
|
|
5
|
+
* direct Node loading (`node --test`, `bizar models explain`). This is
|
|
6
|
+
* NOT a runtime import path for the SDK — the SDK ships the TypeScript
|
|
7
|
+
* source — but the CLI is `mjs` and cannot import TS directly without a
|
|
8
|
+
* build step. The mirror keeps the algorithm byte-identical:
|
|
9
|
+
*
|
|
10
|
+
* - `defaultTierHintForId` regex set matches `cli/commands/models.mjs:defaultTierHint`
|
|
11
|
+
* and the SDK's `defaultTierHintForId` verbatim.
|
|
12
|
+
* - The eligible ranking rules mirror `rankUserSelectedForRole`'s sort
|
|
13
|
+
* key (`eligible desc, capabilityScore desc, hasProfile desc,
|
|
14
|
+
* originalIndex asc`).
|
|
15
|
+
* - `pickFailover` walks the eligible list, skips `attemptedIds`,
|
|
16
|
+
* applies the same transport/availability whitelist, and caps at
|
|
17
|
+
* one failover attempt.
|
|
18
|
+
*
|
|
19
|
+
* If `failover.ts` and `failover-mirror.mjs` ever diverge, the unit
|
|
20
|
+
* tests in `cli/__tests__/models-picker.test.mjs` will fail with a
|
|
21
|
+
* divergence diff.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default tier classification for a model ID. Mirrors
|
|
26
|
+
* `cli/commands/models.mjs:defaultTierHint` and
|
|
27
|
+
* `packages/sdk/src/router/agent-model-registry.ts:defaultTierHintForId`
|
|
28
|
+
* byte-for-byte. Most-specific patterns run first.
|
|
29
|
+
*/
|
|
30
|
+
export function defaultTierHintForId(modelId) {
|
|
31
|
+
const id = String(modelId || "").toLowerCase();
|
|
32
|
+
if (!id) return "default";
|
|
33
|
+
if (/(qwen3\.8|gpt-5|opus|o3-pro|o4-mini|sonnet-4)/.test(id)) return "premium";
|
|
34
|
+
if (/(haiku-4|sonnet-3-7|mini-high|m3-high|grok-3)/.test(id)) return "high";
|
|
35
|
+
if (/(sonnet|gpt-4|m3(-|$)|(^|[^a-z])default($|[^a-z]))/.test(id)) return "default";
|
|
36
|
+
if (/(nano|mini[-/]|flash|lite|tiny|haiku($|[-_]\d))/.test(id)) return "budget";
|
|
37
|
+
return "mid";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Weighted capability score, byte-identical to `scoreCapabilityProfile`.
|
|
42
|
+
* reasoning=0.3, toolCall=0.25, structuredOutput=0.15, attachment=0.1,
|
|
43
|
+
* temperature=0.05, +0.15 when `inputModalities` includes "image".
|
|
44
|
+
* Missing/partial profiles score 0.
|
|
45
|
+
*/
|
|
46
|
+
export function scoreCapabilityProfile(profile) {
|
|
47
|
+
if (!profile || !profile.capabilities) return 0;
|
|
48
|
+
const caps = profile.capabilities;
|
|
49
|
+
let score = 0;
|
|
50
|
+
if (caps.reasoning) score += 0.3;
|
|
51
|
+
if (caps.toolCall) score += 0.25;
|
|
52
|
+
if (caps.structuredOutput) score += 0.15;
|
|
53
|
+
if (caps.attachment) score += 0.1;
|
|
54
|
+
if (caps.temperature) score += 0.05;
|
|
55
|
+
if (Array.isArray(caps.inputModalities) && caps.inputModalities.includes("image")) score += 0.15;
|
|
56
|
+
return Math.round(score * 1e6) / 1e6;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mirror of `evaluateRoleRequirements`. Returns
|
|
61
|
+
* `{ eligible, ineligibleReasons }`. Profiles missing the relevant data
|
|
62
|
+
* (e.g., `null` context tokens) pass unknown-values — the resolver
|
|
63
|
+
* only downgrades when a known value violates a floor.
|
|
64
|
+
*/
|
|
65
|
+
export function evaluateRoleRequirements(profile, requirements, tier) {
|
|
66
|
+
const reasons = [];
|
|
67
|
+
if (typeof requirements?.minContextTokens === "number" && profile?.limits && Number.isFinite(profile.limits.contextTokens) && profile.limits.contextTokens < requirements.minContextTokens) {
|
|
68
|
+
reasons.push(`contextTokens ${profile.limits.contextTokens} < required ${requirements.minContextTokens}`);
|
|
69
|
+
}
|
|
70
|
+
if (requirements?.requireReasoning && profile?.capabilities && profile.capabilities.reasoning !== true) {
|
|
71
|
+
reasons.push("missing required reasoning capability");
|
|
72
|
+
}
|
|
73
|
+
if (requirements?.requireToolCall && profile?.capabilities && profile.capabilities.toolCall !== true) {
|
|
74
|
+
reasons.push("missing required tool-call capability");
|
|
75
|
+
}
|
|
76
|
+
if (requirements?.requireStructuredOutput && profile?.capabilities && profile.capabilities.structuredOutput !== true) {
|
|
77
|
+
reasons.push("missing required structured-output capability");
|
|
78
|
+
}
|
|
79
|
+
if (requirements?.requireImageInput && profile?.capabilities && !(Array.isArray(profile.capabilities.inputModalities) && profile.capabilities.inputModalities.includes("image"))) {
|
|
80
|
+
reasons.push("missing required image input modality");
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(requirements?.preferredTiers) && requirements.preferredTiers.length > 0 && !requirements.preferredTiers.includes(tier)) {
|
|
83
|
+
reasons.push(`tier ${tier} not in preferred list`);
|
|
84
|
+
}
|
|
85
|
+
return { eligible: reasons.length === 0, ineligibleReasons: reasons };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* F-190 / IMP-017 protocol-floor mirror. Byte-identical to
|
|
90
|
+
* `protocolMeets` in `packages/sdk/src/router/model-profile.ts`. Returns
|
|
91
|
+
* an array of strings describing each violated floor; empty when all
|
|
92
|
+
* floors pass. The discriminator is the discriminated
|
|
93
|
+
* `ModelProfile` shape; the legacy F-184 `ModelCapabilityProfile`
|
|
94
|
+
* falls through to an empty array (legacy `evaluateRoleRequirements`
|
|
95
|
+
* owns those floors).
|
|
96
|
+
*
|
|
97
|
+
* IMP-017 reject strings (mirrored):
|
|
98
|
+
* - `context-too-small: <actual> < <required>`
|
|
99
|
+
* - `no-tool-use`
|
|
100
|
+
* - `no-reasoning`
|
|
101
|
+
* - `no-structured-output`
|
|
102
|
+
* - `no-image-input`
|
|
103
|
+
*/
|
|
104
|
+
export function protocolMeetsMirror(discriminatedProfile, requirements) {
|
|
105
|
+
if (!discriminatedProfile || typeof discriminatedProfile !== "object") return [];
|
|
106
|
+
const protocol = discriminatedProfile.protocol;
|
|
107
|
+
if (!protocol || typeof protocol !== "object") return [];
|
|
108
|
+
const reasons = [];
|
|
109
|
+
if (typeof requirements?.minContextTokens === "number" && Number.isFinite(protocol.contextTokens) && protocol.contextTokens < requirements.minContextTokens) {
|
|
110
|
+
reasons.push(`context-too-small: ${protocol.contextTokens} < ${requirements.minContextTokens}`);
|
|
111
|
+
}
|
|
112
|
+
if (requirements?.requireToolCall === true && protocol.toolUse !== true) {
|
|
113
|
+
reasons.push("no-tool-use");
|
|
114
|
+
}
|
|
115
|
+
if (requirements?.requireReasoning === true && protocol.reasoning !== true) {
|
|
116
|
+
reasons.push("no-reasoning");
|
|
117
|
+
}
|
|
118
|
+
if (requirements?.requireStructuredOutput === true && protocol.structuredOutput !== true) {
|
|
119
|
+
reasons.push("no-structured-output");
|
|
120
|
+
}
|
|
121
|
+
if (requirements?.requireImageInput === true && !(Array.isArray(protocol.modalities) && protocol.modalities.includes("image"))) {
|
|
122
|
+
reasons.push("no-image-input");
|
|
123
|
+
}
|
|
124
|
+
return reasons;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Rank user-selected models for a role. Byte-identical algorithm to
|
|
129
|
+
* `packages/sdk/src/router/agent-model-registry.ts:rankUserSelectedForRole`.
|
|
130
|
+
*
|
|
131
|
+
* Sort key (descending primary, ascending tie-breaker):
|
|
132
|
+
* 1. `eligible` desc
|
|
133
|
+
* 2. `capabilityScore` desc
|
|
134
|
+
* 3. `hasProfile` desc
|
|
135
|
+
* 4. `originalIndex` asc
|
|
136
|
+
*
|
|
137
|
+
* @param {object} registry
|
|
138
|
+
* @param {string} role
|
|
139
|
+
* @param {object} [requirements]
|
|
140
|
+
* @returns {{ ranked: object[], eligible: object[] }}
|
|
141
|
+
*/
|
|
142
|
+
export function rankUserSelectedForRole(registry, role, requirements = {}) {
|
|
143
|
+
const userSelected = registry.userSelected;
|
|
144
|
+
if (!userSelected || !Array.isArray(userSelected.models) || userSelected.models.length === 0) {
|
|
145
|
+
return { ranked: [], eligible: [] };
|
|
146
|
+
}
|
|
147
|
+
const tierHints = userSelected.tierHints;
|
|
148
|
+
const profiles = userSelected.profiles;
|
|
149
|
+
const discriminatedProfiles = userSelected.discriminatedProfiles && typeof userSelected.discriminatedProfiles === "object"
|
|
150
|
+
? userSelected.discriminatedProfiles
|
|
151
|
+
: {};
|
|
152
|
+
const ranked = userSelected.models
|
|
153
|
+
.map((id, originalIndex) => {
|
|
154
|
+
const profile = profiles?.[id];
|
|
155
|
+
const discriminatedProfile = discriminatedProfiles?.[id];
|
|
156
|
+
const hasProfile = Boolean(profile) || Boolean(discriminatedProfile);
|
|
157
|
+
const tier = (tierHints && typeof tierHints[id] === "string" ? tierHints[id] : defaultTierHintForId(id));
|
|
158
|
+
const legacy = evaluateRoleRequirements(profile, requirements, tier);
|
|
159
|
+
const protocolReasons = protocolMeetsMirror(discriminatedProfile, requirements);
|
|
160
|
+
const ineligibleReasons = [...protocolReasons, ...legacy.ineligibleReasons];
|
|
161
|
+
const eligible = ineligibleReasons.length === 0;
|
|
162
|
+
const capabilityScore = scoreCapabilityProfile(profile);
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
tier,
|
|
166
|
+
eligible,
|
|
167
|
+
ineligibleReasons,
|
|
168
|
+
capabilityScore,
|
|
169
|
+
hasProfile,
|
|
170
|
+
originalIndex,
|
|
171
|
+
...(discriminatedProfile ? { discriminatedProfile, reasons: protocolReasons } : {}),
|
|
172
|
+
...(discriminatedProfile?.measured ? { measured: discriminatedProfile.measured } : {}),
|
|
173
|
+
...(discriminatedProfile?.provenance ? { provenance: discriminatedProfile.provenance } : {}),
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
ranked.sort((a, b) => {
|
|
177
|
+
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
|
|
178
|
+
if (a.capabilityScore !== b.capabilityScore) return b.capabilityScore - a.capabilityScore;
|
|
179
|
+
if (a.hasProfile !== b.hasProfile) return a.hasProfile ? -1 : 1;
|
|
180
|
+
return a.originalIndex - b.originalIndex;
|
|
181
|
+
});
|
|
182
|
+
const eligible = ranked.filter((entry) => entry.eligible);
|
|
183
|
+
// role is consumed only for future per-role filtering / logging hooks.
|
|
184
|
+
void role;
|
|
185
|
+
return { ranked, eligible };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const TRANSPORT_OR_AVAILABILITY = new Set([
|
|
189
|
+
"invalid-model",
|
|
190
|
+
"auth-failure",
|
|
191
|
+
"rate-limit",
|
|
192
|
+
"timeout",
|
|
193
|
+
"provider-outage",
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Walk the ranked eligible list and pick the next ID that has not been
|
|
198
|
+
* attempted. Returns the same `FailoverVerdict` shape as the TS module.
|
|
199
|
+
*
|
|
200
|
+
* - Non-transport reason → exhausts immediately (no failover).
|
|
201
|
+
* - First eligible ID already attempted → it stays as `primary`; the
|
|
202
|
+
* next un-attempted eligible ID becomes the failover.
|
|
203
|
+
* - One-failover cap. A second transport failure on the failover
|
|
204
|
+
* target marks subsequent entries as `exhausted` and refuses to walk.
|
|
205
|
+
*/
|
|
206
|
+
export function pickFailover({ registry, role, requirements, attemptedIds, failure, primaryDecisionId }) {
|
|
207
|
+
const attempted = new Set((attemptedIds || []).filter((id) => typeof id === "string"));
|
|
208
|
+
const routingDecisionId = typeof primaryDecisionId === "string" && primaryDecisionId.length > 0 ? primaryDecisionId : null;
|
|
209
|
+
// IMP-020 / F-192: ordered list of attempted-and-failed IDs. The
|
|
210
|
+
// learner updates only the FINAL model's posterior; the IMP-018
|
|
211
|
+
// evidence store records per-attempt outcomes against this list. We
|
|
212
|
+
// always populate it with the primary's id when the primary was
|
|
213
|
+
// already attempted, and with subsequent attempted failover IDs.
|
|
214
|
+
const failoverFrom = [];
|
|
215
|
+
|
|
216
|
+
if (!TRANSPORT_OR_AVAILABILITY.has(failure)) {
|
|
217
|
+
return {
|
|
218
|
+
primary: null,
|
|
219
|
+
failover: null,
|
|
220
|
+
attempts: 0,
|
|
221
|
+
exhaustReason: failure,
|
|
222
|
+
chain: [{ id: "", eligible: false, capabilityScore: 0, attempted: false, outcome: "skipped-non-transport-reason" }],
|
|
223
|
+
routingDecisionId,
|
|
224
|
+
failoverFrom,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { eligible, ranked } = rankUserSelectedForRole(registry, role, requirements);
|
|
229
|
+
if (ranked.length === 0) {
|
|
230
|
+
return { primary: null, failover: null, attempts: 0, exhaustReason: failure, chain: [], routingDecisionId, failoverFrom };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const head = ranked[0];
|
|
234
|
+
const chain = [{
|
|
235
|
+
id: head.id,
|
|
236
|
+
eligible: head.eligible,
|
|
237
|
+
capabilityScore: head.capabilityScore,
|
|
238
|
+
attempted: attempted.has(head.id),
|
|
239
|
+
outcome: "primary",
|
|
240
|
+
}];
|
|
241
|
+
// IMP-020: if the primary was already attempted, it counts as a
|
|
242
|
+
// failed-and-skipped ID in the `failoverFrom` list.
|
|
243
|
+
if (attempted.has(head.id)) {
|
|
244
|
+
failoverFrom.push(head.id);
|
|
245
|
+
}
|
|
246
|
+
const primary = { id: head.id, reason: failure };
|
|
247
|
+
|
|
248
|
+
let failover = null;
|
|
249
|
+
let exhaustedAtTopLevel = false;
|
|
250
|
+
|
|
251
|
+
for (const entry of eligible) {
|
|
252
|
+
if (entry.id === head.id) continue;
|
|
253
|
+
if (attempted.has(entry.id)) {
|
|
254
|
+
failoverFrom.push(entry.id);
|
|
255
|
+
chain.push({
|
|
256
|
+
id: entry.id,
|
|
257
|
+
eligible: entry.eligible,
|
|
258
|
+
capabilityScore: entry.capabilityScore,
|
|
259
|
+
attempted: true,
|
|
260
|
+
outcome: "skipped-already-attempted",
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (failover === null) {
|
|
265
|
+
failover = { id: entry.id, reason: failure };
|
|
266
|
+
chain.push({
|
|
267
|
+
id: entry.id,
|
|
268
|
+
eligible: entry.eligible,
|
|
269
|
+
capabilityScore: entry.capabilityScore,
|
|
270
|
+
attempted: false,
|
|
271
|
+
outcome: "failover",
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
chain.push({
|
|
275
|
+
id: entry.id,
|
|
276
|
+
eligible: entry.eligible,
|
|
277
|
+
capabilityScore: entry.capabilityScore,
|
|
278
|
+
attempted: false,
|
|
279
|
+
outcome: "exhausted",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (failover === null) exhaustedAtTopLevel = true;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
primary,
|
|
288
|
+
failover,
|
|
289
|
+
attempts: attempted.size + (failover !== null ? 1 : 0),
|
|
290
|
+
exhaustReason: exhaustedAtTopLevel ? failure : null,
|
|
291
|
+
chain,
|
|
292
|
+
routingDecisionId,
|
|
293
|
+
failoverFrom,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Convenience classifier for the dispatch wrapper.
|
|
299
|
+
*/
|
|
300
|
+
export function classifyError(message) {
|
|
301
|
+
const m = String(message || "").toLowerCase();
|
|
302
|
+
if (!m) return "invalid-model";
|
|
303
|
+
if (/(context.*length|context.*overflow|context.*window|too long|maximum context)/.test(m)) return "context-overflow";
|
|
304
|
+
if (/(429|rate.?limit|too many requests|quota)/.test(m)) return "rate-limit";
|
|
305
|
+
if (/(401|403|unauthorized|forbidden|auth.?token|invalid.*api.*key)/.test(m)) return "auth-failure";
|
|
306
|
+
if (/(timeout|timed out|etimedout|aborted|deadline)/.test(m)) return "timeout";
|
|
307
|
+
if (/(502|503|504|bad gateway|service unavailable|gateway timeout|provider outage|upstream)/.test(m)) return "provider-outage";
|
|
308
|
+
if (/(invalid.*model|unknown.*model|model not found|no such model|not a valid model)/.test(m)) return "invalid-model";
|
|
309
|
+
if (/(quality|incomplete|truncated|garbage|low quality|incoherent)/.test(m)) return "model-quality";
|
|
310
|
+
return "invalid-model";
|
|
311
|
+
}
|