@polderlabs/bizar 10.23.6 → 10.23.8
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 +366 -168
- package/cli/doctor.mjs +9 -0
- package/cli/provision.mjs +15 -5
- package/config/claude/agents/office-manager.md +17 -0
- package/config/claude/hooks/agent-model-guard.mjs +16 -30
- package/config/claude/hooks/sessionstart-model-sync.mjs +11 -1
- package/config/claude/hooks/workflow-route-guard.mjs +6 -2
- package/config/workflows/bizar-debug.js +39 -11
- package/config/workflows/bizar-implement.js +38 -2
- package/config/workflows/bizar-research.js +40 -24
- package/config/workflows/lib/dispatch.js +7 -10
- package/config/workflows/lib/native-contract.mjs +96 -0
- package/config/workflows/ultracode-research.js +40 -9
- package/config/workflows/ultracode-review.js +39 -6
- package/config/workflows/ultracode.js +39 -18
- 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/models.mjs
CHANGED
|
@@ -189,7 +189,7 @@ export async function fetchProviderCatalog({
|
|
|
189
189
|
return fetchModelsDevCatalog({ fetchFn, timeoutMs, url });
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
function flattenModelsDevCatalog(catalog) {
|
|
192
|
+
function flattenModelsDevCatalog(catalog, { providerCatalog = false } = {}) {
|
|
193
193
|
const entries = new Map();
|
|
194
194
|
const add = (id, value) => {
|
|
195
195
|
if (typeof id !== 'string' || !id.trim() || !value || typeof value !== 'object' || Array.isArray(value)) return;
|
|
@@ -213,15 +213,48 @@ function flattenModelsDevCatalog(catalog) {
|
|
|
213
213
|
for (const [modelId, model] of Object.entries(value)) add(modelId, model);
|
|
214
214
|
}
|
|
215
215
|
if (key === 'providers') {
|
|
216
|
-
for (const provider of Object.
|
|
216
|
+
for (const [providerId, provider] of Object.entries(value)) {
|
|
217
217
|
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) continue;
|
|
218
|
-
for (const [modelId, model] of Object.entries(provider.models || {}))
|
|
218
|
+
for (const [modelId, model] of Object.entries(provider.models || {})) {
|
|
219
|
+
// catalog.json commonly uses the same bare model id under several
|
|
220
|
+
// providers. Keep that identity in the key: a bare-id map would be
|
|
221
|
+
// last-writer-wins and could attach one provider's serving facts to
|
|
222
|
+
// another provider's model.
|
|
223
|
+
const qualifiedId = providerCatalog && !modelId.includes('/')
|
|
224
|
+
? `${providerId}/${modelId}`
|
|
225
|
+
: modelId;
|
|
226
|
+
add(qualifiedId, { provider: providerId, ...model });
|
|
227
|
+
}
|
|
219
228
|
}
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
return entries;
|
|
223
232
|
}
|
|
224
233
|
|
|
234
|
+
function uniqueCatalogMatch(entries, gatewayId, { requireProvider = false } = {}) {
|
|
235
|
+
const raw = String(gatewayId || '').trim().toLowerCase();
|
|
236
|
+
if (!raw) return null;
|
|
237
|
+
const exact = entries.get(raw);
|
|
238
|
+
if (exact) return exact;
|
|
239
|
+
|
|
240
|
+
const wanted = normalizedModelIdentity(raw);
|
|
241
|
+
const values = [...entries.values()];
|
|
242
|
+
const providerMatches = wanted.provider
|
|
243
|
+
? values.filter((entry) => {
|
|
244
|
+
const found = normalizedModelIdentity(entry.id);
|
|
245
|
+
return found.model === wanted.model && found.provider === wanted.provider;
|
|
246
|
+
})
|
|
247
|
+
: [];
|
|
248
|
+
if (providerMatches.length === 1) return providerMatches[0];
|
|
249
|
+
if (requireProvider) return null;
|
|
250
|
+
|
|
251
|
+
// Wrapper namespaces (for example cx/ or a gateway-specific prefix) are
|
|
252
|
+
// not canonical providers. A canonical suffix is still safe when exactly
|
|
253
|
+
// one catalog entry owns it; collisions deliberately remain unmatched.
|
|
254
|
+
const suffixMatches = values.filter((entry) => normalizedModelIdentity(entry.id).model === wanted.model);
|
|
255
|
+
return suffixMatches.length === 1 ? suffixMatches[0] : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
225
258
|
function normalizedModelIdentity(id) {
|
|
226
259
|
const raw = String(id || '').trim().toLowerCase();
|
|
227
260
|
const slash = raw.indexOf('/');
|
|
@@ -276,6 +309,7 @@ export function toCapabilityProfile(gatewayId, match, matchType, confidence) {
|
|
|
276
309
|
weights: Array.isArray(match.weights) ? match.weights : [],
|
|
277
310
|
benchmarks: Array.isArray(match.benchmarks) ? match.benchmarks : [],
|
|
278
311
|
cost: match.cost && typeof match.cost === 'object' ? match.cost : null,
|
|
312
|
+
serving: match.serving && typeof match.serving === 'object' ? match.serving : null,
|
|
279
313
|
metadata: {
|
|
280
314
|
source: 'models.dev',
|
|
281
315
|
sourceUrl: MODELS_DEV_CATALOG_URL,
|
|
@@ -302,7 +336,6 @@ export function toCapabilityProfile(gatewayId, match, matchType, confidence) {
|
|
|
302
336
|
*/
|
|
303
337
|
export function enrichModelsWithCapabilities(candidates, catalog) {
|
|
304
338
|
const entries = flattenModelsDevCatalog(catalog);
|
|
305
|
-
const all = [...entries.values()];
|
|
306
339
|
return (Array.isArray(candidates) ? candidates : []).map((candidate) => {
|
|
307
340
|
const gatewayId = candidate.id;
|
|
308
341
|
const exact = entries.get(String(gatewayId).toLowerCase());
|
|
@@ -310,14 +343,9 @@ export function enrichModelsWithCapabilities(candidates, catalog) {
|
|
|
310
343
|
const profile = toCapabilityProfile(gatewayId, exact, 'exact-id', 0.9);
|
|
311
344
|
return { ...candidate, profile, contextWindow: profile.limits.contextTokens };
|
|
312
345
|
}
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
if (!wanted.model || found.model !== wanted.model) return false;
|
|
317
|
-
return !wanted.provider || !found.provider || found.provider === wanted.provider;
|
|
318
|
-
});
|
|
319
|
-
if (matches.length === 1) {
|
|
320
|
-
const profile = toCapabilityProfile(gatewayId, matches[0], 'unique-normalized-id', 0.7);
|
|
346
|
+
const match = uniqueCatalogMatch(entries, gatewayId);
|
|
347
|
+
if (match) {
|
|
348
|
+
const profile = toCapabilityProfile(gatewayId, match, 'unique-normalized-id', 0.7);
|
|
321
349
|
return { ...candidate, profile, contextWindow: profile.limits.contextTokens };
|
|
322
350
|
}
|
|
323
351
|
// Models.dev miss: if the candidate carries gateway-supplied label /
|
|
@@ -364,37 +392,47 @@ export function enrichModelsWithCapabilities(candidates, catalog) {
|
|
|
364
392
|
});
|
|
365
393
|
}
|
|
366
394
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
395
|
+
function gatewayFallbackProfile(candidate) {
|
|
396
|
+
if (!candidate?._gateway) return null;
|
|
397
|
+
return {
|
|
398
|
+
gatewayId: candidate.id,
|
|
399
|
+
baseModel: candidate.id,
|
|
400
|
+
name: candidate._gateway.name ?? candidate._gateway.display_name ?? null,
|
|
401
|
+
family: null,
|
|
402
|
+
description: candidate._gateway.description ?? null,
|
|
403
|
+
summary: null,
|
|
404
|
+
capabilities: {
|
|
405
|
+
attachment: false,
|
|
406
|
+
reasoning: false,
|
|
407
|
+
toolCall: false,
|
|
408
|
+
structuredOutput: false,
|
|
409
|
+
temperature: true,
|
|
410
|
+
inputModalities: ['text'],
|
|
411
|
+
outputModalities: ['text'],
|
|
412
|
+
},
|
|
413
|
+
limits: { contextTokens: null, inputTokens: null, outputTokens: null },
|
|
414
|
+
metadata: { source: 'gateway-fallback', matchType: 'gateway-fallback', confidence: 0 },
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function withClearedTimeout(work, timeoutMs, label) {
|
|
419
|
+
let timer;
|
|
420
|
+
return Promise.race([
|
|
421
|
+
Promise.resolve().then(work),
|
|
422
|
+
new Promise((_, reject) => {
|
|
423
|
+
timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs);
|
|
424
|
+
}),
|
|
425
|
+
]).finally(() => clearTimeout(timer));
|
|
380
426
|
}
|
|
381
427
|
|
|
382
428
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* on every interactive session that the user then aborted.
|
|
387
|
-
*
|
|
388
|
-
* After Phase 2 the catalog fetch moves to AFTER picker confirmation:
|
|
389
|
-
* - `--list` and `--set` skip the fetch entirely.
|
|
390
|
-
* - The interactive path only fetches when the user actually picked
|
|
391
|
-
* one or more models, and only the picked IDs are enriched.
|
|
429
|
+
* Best-effort metadata enrichment. Interactive callers pass the complete
|
|
430
|
+
* candidate set before rendering; non-interactive callers may pass only the
|
|
431
|
+
* IDs they need. Empty input is returned without contacting Models.dev.
|
|
392
432
|
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* candidate's Phase 1 `_gateway.name` contract (when present); when no
|
|
397
|
-
* `_gateway` block is attached, the profile is `null`.
|
|
433
|
+
* The base and provider catalogs are fetched concurrently exactly once. Their
|
|
434
|
+
* timeout handles are cleared as soon as each request settles. Catalog misses
|
|
435
|
+
* fall back to the gateway's renderer-safe profile shape.
|
|
398
436
|
*
|
|
399
437
|
* @param {{
|
|
400
438
|
* candidates: Array<{ id: string, owned_by?: string|null, _gateway?: { name?: string|null, display_name?: string|null, description?: string|null } }>,
|
|
@@ -414,29 +452,29 @@ export async function enrichPicksByMetadata({
|
|
|
414
452
|
timeoutMs = 3000,
|
|
415
453
|
concurrency = 8,
|
|
416
454
|
} = {}) {
|
|
455
|
+
const out = new Map();
|
|
456
|
+
const ids = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id) : [];
|
|
457
|
+
if (ids.length === 0) return { profiles: out, modelsDev: {}, providerCatalog: {} };
|
|
458
|
+
|
|
417
459
|
// Wholesale catalog fetch — best-effort. A network failure on the
|
|
418
460
|
// initial fetch degrades to an empty map; per-id timeouts on the
|
|
419
461
|
// downstream enrichment path fall back to `_gateway.name`.
|
|
420
462
|
// The wholesale fetch inherits `timeoutMs` so a hung stub does not
|
|
421
463
|
// block the picker-confirmation step indefinitely.
|
|
422
|
-
const fetchWithTimeout = (fn, label) =>
|
|
423
|
-
fn({}),
|
|
424
|
-
|
|
425
|
-
|
|
464
|
+
const fetchWithTimeout = (fn, label) => withClearedTimeout(
|
|
465
|
+
() => fn({ timeoutMs }),
|
|
466
|
+
timeoutMs,
|
|
467
|
+
`enrichPicksByMetadata: ${label} fetch`,
|
|
468
|
+
).catch(() => ({}));
|
|
426
469
|
const [catalog, providerCatalog] = await Promise.all([
|
|
427
470
|
fetchWithTimeout(fetchFn, 'base catalog'),
|
|
428
471
|
typeof providerFetchFn === 'function' ? fetchWithTimeout(providerFetchFn, 'provider catalog') : Promise.resolve({}),
|
|
429
472
|
]);
|
|
430
473
|
const catalogMap = flattenModelsDevCatalog(catalog);
|
|
431
|
-
const providerMap = flattenModelsDevCatalog(providerCatalog);
|
|
432
|
-
|
|
433
|
-
const out = new Map();
|
|
434
|
-
const ids = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id) : [];
|
|
474
|
+
const providerMap = flattenModelsDevCatalog(providerCatalog, { providerCatalog: true });
|
|
435
475
|
// Stable order so the work array indexes are predictable for the
|
|
436
476
|
// bounded runner below.
|
|
437
477
|
const work = ids.map((id, index) => ({ id, index }));
|
|
438
|
-
if (work.length === 0) return { profiles: out, modelsDev: catalog };
|
|
439
|
-
|
|
440
478
|
const findCandidate = (id) => (Array.isArray(candidates) ? candidates.find((c) => c && c.id === id) : null);
|
|
441
479
|
|
|
442
480
|
// Bounded-concurrency worker pool. Each worker pulls jobs off the
|
|
@@ -456,19 +494,15 @@ export async function enrichPicksByMetadata({
|
|
|
456
494
|
continue;
|
|
457
495
|
}
|
|
458
496
|
try {
|
|
459
|
-
await Promise.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|| providerMap.get(stripProvider(id).toLowerCase());
|
|
469
|
-
const match = baseMatch && providerMatch
|
|
470
|
-
? { ...baseMatch, ...providerMatch }
|
|
471
|
-
: (baseMatch || providerMatch);
|
|
497
|
+
await Promise.resolve().then(() => {
|
|
498
|
+
const baseMatch = uniqueCatalogMatch(catalogMap, id);
|
|
499
|
+
// Serving metadata is provider-specific. It is eligible only
|
|
500
|
+
// for an exact or normalized explicit provider match; suffix-only
|
|
501
|
+
// matching here would reintroduce cross-provider collisions.
|
|
502
|
+
const providerMatch = uniqueCatalogMatch(providerMap, id, { requireProvider: true });
|
|
503
|
+
const match = baseMatch
|
|
504
|
+
? { ...baseMatch, ...(providerMatch ? { serving: providerMatch, cost: providerMatch.cost ?? baseMatch.cost } : {}) }
|
|
505
|
+
: providerMatch;
|
|
472
506
|
let profile;
|
|
473
507
|
if (match) {
|
|
474
508
|
// Re-use the existing capability-profile shape; the new
|
|
@@ -477,42 +511,18 @@ export async function enrichPicksByMetadata({
|
|
|
477
511
|
// enriched (confirmed picks only).
|
|
478
512
|
profile = toCapabilityProfile(id, match, 'exact-id', 0.9);
|
|
479
513
|
} else if (candidate._gateway) {
|
|
480
|
-
profile =
|
|
481
|
-
name: candidate._gateway.name ?? candidate._gateway.display_name ?? null,
|
|
482
|
-
description: candidate._gateway.description ?? null,
|
|
483
|
-
summary: null,
|
|
484
|
-
ownedBy: candidate.owned_by ?? null,
|
|
485
|
-
supportsTools: null,
|
|
486
|
-
supportsVision: null,
|
|
487
|
-
contextWindow: null,
|
|
488
|
-
costTier: null,
|
|
489
|
-
confidence: 0,
|
|
490
|
-
metadata: { source: 'gateway-fallback' },
|
|
491
|
-
};
|
|
514
|
+
profile = gatewayFallbackProfile(candidate);
|
|
492
515
|
} else {
|
|
493
516
|
profile = null;
|
|
494
517
|
}
|
|
495
518
|
out.set(id, profile);
|
|
496
|
-
})
|
|
497
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('enrichPicksByMetadata: per-id timeout')), timeoutMs)),
|
|
498
|
-
]);
|
|
519
|
+
});
|
|
499
520
|
} catch {
|
|
500
521
|
// Per-id failure (timeout or thrown). Fall back to `_gateway.name`
|
|
501
522
|
// when present so the Phase 1 contract still surfaces a name;
|
|
502
523
|
// otherwise the profile is null.
|
|
503
524
|
if (candidate._gateway) {
|
|
504
|
-
out.set(id,
|
|
505
|
-
name: candidate._gateway.name ?? candidate._gateway.display_name ?? null,
|
|
506
|
-
description: candidate._gateway.description ?? null,
|
|
507
|
-
summary: null,
|
|
508
|
-
ownedBy: candidate.owned_by ?? null,
|
|
509
|
-
supportsTools: null,
|
|
510
|
-
supportsVision: null,
|
|
511
|
-
contextWindow: null,
|
|
512
|
-
costTier: null,
|
|
513
|
-
confidence: 0,
|
|
514
|
-
metadata: { source: 'gateway-fallback' },
|
|
515
|
-
});
|
|
525
|
+
out.set(id, gatewayFallbackProfile(candidate));
|
|
516
526
|
} else {
|
|
517
527
|
out.set(id, null);
|
|
518
528
|
}
|
|
@@ -520,17 +530,20 @@ export async function enrichPicksByMetadata({
|
|
|
520
530
|
}
|
|
521
531
|
});
|
|
522
532
|
await Promise.all(workers);
|
|
523
|
-
return { profiles: out, modelsDev: catalog };
|
|
533
|
+
return { profiles: out, modelsDev: catalog, providerCatalog };
|
|
524
534
|
}
|
|
525
535
|
|
|
526
536
|
function capabilityLabel(profile) {
|
|
527
537
|
if (!profile) return 'metadata unavailable';
|
|
538
|
+
const capabilities = profile.capabilities || {};
|
|
539
|
+
const modalities = Array.isArray(capabilities.inputModalities) ? capabilities.inputModalities : [];
|
|
540
|
+
const limits = profile.limits || {};
|
|
528
541
|
const caps = [];
|
|
529
|
-
if (
|
|
530
|
-
if (
|
|
531
|
-
if (
|
|
532
|
-
if (
|
|
533
|
-
if (
|
|
542
|
+
if (capabilities.reasoning) caps.push('reasoning');
|
|
543
|
+
if (capabilities.toolCall) caps.push('tools');
|
|
544
|
+
if (capabilities.structuredOutput) caps.push('structured');
|
|
545
|
+
if (modalities.some((m) => m !== 'text')) caps.push('multimodal');
|
|
546
|
+
if (limits.contextTokens) caps.push(formatContextTokens(limits.contextTokens));
|
|
534
547
|
return caps.length > 0 ? caps.join(', ') : 'basic text';
|
|
535
548
|
}
|
|
536
549
|
|
|
@@ -858,16 +871,19 @@ export function applyModels({ routerPath, models, tierHints = {}, profiles = {},
|
|
|
858
871
|
const { kept: list } = filterCandidatesByDisabledProviders(incoming, disabled);
|
|
859
872
|
const hints = { ...(tierHints || {}) };
|
|
860
873
|
for (const id of list) if (!hints[id]) hints[id] = defaultTierHint(id);
|
|
874
|
+
const router = existsSync(routerPath)
|
|
875
|
+
? JSON.parse(readFileSync(routerPath, 'utf8'))
|
|
876
|
+
: {};
|
|
877
|
+
const previousProfiles = router?.userSelected?.profiles || {};
|
|
861
878
|
const block = {
|
|
862
879
|
models: list,
|
|
863
880
|
lastUpdated: new Date().toISOString(),
|
|
864
881
|
source,
|
|
865
882
|
tierHints: hints,
|
|
866
|
-
profiles: Object.fromEntries(list
|
|
883
|
+
profiles: Object.fromEntries(list
|
|
884
|
+
.filter((id) => profiles[id] || previousProfiles[id])
|
|
885
|
+
.map((id) => [id, profiles[id] || previousProfiles[id]])),
|
|
867
886
|
};
|
|
868
|
-
const router = existsSync(routerPath)
|
|
869
|
-
? JSON.parse(readFileSync(routerPath, 'utf8'))
|
|
870
|
-
: {};
|
|
871
887
|
router.userSelected = block;
|
|
872
888
|
if (!router.version) router.version = '13.0.0';
|
|
873
889
|
// Do NOT auto-inject a default endpoint here. Operators configure the
|
|
@@ -931,8 +947,9 @@ export function partitionStalePicks({ liveIds, pickedIds, disabledProviders }) {
|
|
|
931
947
|
*
|
|
932
948
|
* Behavior:
|
|
933
949
|
* - Reads `settings.json` if present; preserves every other field.
|
|
934
|
-
* -
|
|
935
|
-
*
|
|
950
|
+
* - Maps every recognized Claude alias into picked IDs that are also in
|
|
951
|
+
* `liveIds`, preventing internal alias normalization from reaching an
|
|
952
|
+
* unconfigured provider default.
|
|
936
953
|
* - Atomic replace via temp-file + rename (matches `applyModels`).
|
|
937
954
|
* - When `settingsJsonPath` is provided (tests), uses that instead of
|
|
938
955
|
* `~/.claude/settings.json`.
|
|
@@ -991,6 +1008,12 @@ export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = [],
|
|
|
991
1008
|
// must be keys; configured gateway aliases are values. This also suppresses
|
|
992
1009
|
// print-mode `[claude-code:unrecognized_model]` diagnostics for Agent SDK calls.
|
|
993
1010
|
settings.modelOverrides = buildClaudeModelOverrides(synced);
|
|
1011
|
+
if (requiresGatewayModelDiscovery(synced)) {
|
|
1012
|
+
settings.env = {
|
|
1013
|
+
...(settings.env || {}),
|
|
1014
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
994
1017
|
const previousModel = typeof settings.model === 'string' ? settings.model : null;
|
|
995
1018
|
const previousContext = profiles?.[previousModel]?.limits?.contextTokens;
|
|
996
1019
|
const nextModel = synced[0] || null;
|
|
@@ -1052,8 +1075,20 @@ export function buildClaudeModelOverrides(modelIds) {
|
|
|
1052
1075
|
const unique = [...new Set((Array.isArray(modelIds) ? modelIds : [])
|
|
1053
1076
|
.filter((id) => typeof id === 'string' && id.trim())
|
|
1054
1077
|
.map((id) => id.trim()))];
|
|
1055
|
-
|
|
1056
|
-
|
|
1078
|
+
if (unique.length === 0) return {};
|
|
1079
|
+
// Cover every built-in alias: the workflow runtime may normalize an Agent
|
|
1080
|
+
// request through one of these names, and no alias may escape to an
|
|
1081
|
+
// unconfigured Anthropic default.
|
|
1082
|
+
return Object.fromEntries(CLAUDE_MODEL_OVERRIDE_KEYS
|
|
1083
|
+
.map((key, index) => [key, unique[index % unique.length]]));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Custom gateway IDs must be discoverable to Claude's SDK/subagent path. */
|
|
1087
|
+
export function requiresGatewayModelDiscovery(modelIds) {
|
|
1088
|
+
return (Array.isArray(modelIds) ? modelIds : []).some((id) => {
|
|
1089
|
+
if (typeof id !== 'string' || !id.trim()) return false;
|
|
1090
|
+
return !/^(?:claude(?:-|$)|anthropic(?:[./-]|$))/i.test(id.trim());
|
|
1091
|
+
});
|
|
1057
1092
|
}
|
|
1058
1093
|
|
|
1059
1094
|
/**
|
|
@@ -1490,10 +1525,12 @@ export function explainSelection({ routerPath, role, requirements = {} } = {}) {
|
|
|
1490
1525
|
* Interactive multi-select picker. Pure function over streams — testable.
|
|
1491
1526
|
*
|
|
1492
1527
|
* Branching: when stdin is a TTY with raw-mode support, the picker drives a
|
|
1493
|
-
* keypress-driven checklist (arrow keys /
|
|
1494
|
-
* esc / ?).
|
|
1528
|
+
* keypress-driven checklist with direct type-to-search (arrow keys / space /
|
|
1529
|
+
* Ctrl+A / Ctrl+N / enter / esc / backspace / ?). When stdin is not a TTY (pipes, CI,
|
|
1530
|
+
* tests) the picker falls
|
|
1495
1531
|
* through to a line-mode loop that accepts a space-separated index list,
|
|
1496
|
-
* `all`, `none`, `toggle <i>`, an empty line
|
|
1532
|
+
* `all`, `none`, `toggle <i>`, `/query`, `search query`, an empty line
|
|
1533
|
+
* (confirm), or `q` (quit). Both
|
|
1497
1534
|
* branches return the chosen IDs in the user's most-recent selection order,
|
|
1498
1535
|
* so external callers and existing tests see a single `Promise<string[]>`.
|
|
1499
1536
|
*
|
|
@@ -1537,10 +1574,112 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
|
|
|
1537
1574
|
}
|
|
1538
1575
|
}
|
|
1539
1576
|
|
|
1577
|
+
function normalizeSearchText(value) {
|
|
1578
|
+
return String(value || '')
|
|
1579
|
+
.toLowerCase()
|
|
1580
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
1581
|
+
.trim()
|
|
1582
|
+
.replace(/\s+/g, ' ');
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Score a fuzzy query against one text field. Exact, prefix, token-prefix,
|
|
1587
|
+
* and substring matches lead; ordered subsequences remain useful for compact
|
|
1588
|
+
* queries such as `gpt56l`. A negative score means no match.
|
|
1589
|
+
*/
|
|
1590
|
+
function fuzzyTextScore(value, query, allowSubsequence = true) {
|
|
1591
|
+
const text = normalizeSearchText(value);
|
|
1592
|
+
const needle = normalizeSearchText(query);
|
|
1593
|
+
if (!needle) return 0;
|
|
1594
|
+
if (!text) return -1;
|
|
1595
|
+
if (text === needle) return 10_000;
|
|
1596
|
+
if (text.startsWith(needle)) return 9_000 - (text.length - needle.length);
|
|
1597
|
+
|
|
1598
|
+
const tokenIndex = text.split(' ').findIndex((token) => token.startsWith(needle));
|
|
1599
|
+
if (tokenIndex >= 0) return 8_000 - tokenIndex * 10;
|
|
1600
|
+
|
|
1601
|
+
const substringIndex = text.indexOf(needle);
|
|
1602
|
+
if (substringIndex >= 0) return 7_000 - substringIndex;
|
|
1603
|
+
|
|
1604
|
+
// One- and two-character subsequences are too permissive across model
|
|
1605
|
+
// descriptions. Short searches must be contiguous.
|
|
1606
|
+
if (!allowSubsequence || needle.replace(/\s/g, '').length < 3) return -1;
|
|
1607
|
+
|
|
1608
|
+
let textIndex = 0;
|
|
1609
|
+
let first = -1;
|
|
1610
|
+
let previous = -1;
|
|
1611
|
+
let gaps = 0;
|
|
1612
|
+
let boundaries = 0;
|
|
1613
|
+
for (const char of needle) {
|
|
1614
|
+
if (char === ' ') continue;
|
|
1615
|
+
const found = text.indexOf(char, textIndex);
|
|
1616
|
+
if (found < 0) return -1;
|
|
1617
|
+
if (first < 0) first = found;
|
|
1618
|
+
if (previous >= 0) gaps += found - previous - 1;
|
|
1619
|
+
if (found === 0 || text[found - 1] === ' ') boundaries++;
|
|
1620
|
+
previous = found;
|
|
1621
|
+
textIndex = found + 1;
|
|
1622
|
+
}
|
|
1623
|
+
const compactNeedleLength = needle.replace(/\s/g, '').length;
|
|
1624
|
+
if (gaps > Math.max(8, compactNeedleLength * 2)) return -1;
|
|
1625
|
+
return 4_000 + boundaries * 25 - first * 2 - gaps;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Pure fuzzy filter used by both picker modes. Match quality determines
|
|
1630
|
+
* inclusion while gateway order remains stable as the query changes.
|
|
1631
|
+
*
|
|
1632
|
+
* @param {Array<{id: string, profile?: object, _gateway?: object}>} candidates
|
|
1633
|
+
* @param {string} query
|
|
1634
|
+
* @returns {Array<{id: string, profile?: object, _gateway?: object}>}
|
|
1635
|
+
*/
|
|
1636
|
+
export function filterModelCandidates(candidates, query) {
|
|
1637
|
+
if (!Array.isArray(candidates)) return [];
|
|
1638
|
+
const needle = normalizeSearchText(query);
|
|
1639
|
+
if (!needle) return [...candidates];
|
|
1640
|
+
|
|
1641
|
+
return candidates
|
|
1642
|
+
.map((candidate, index) => {
|
|
1643
|
+
const profile = candidate?.profile || {};
|
|
1644
|
+
const gateway = candidate?._gateway || {};
|
|
1645
|
+
const fields = [
|
|
1646
|
+
[candidate?.id, 300, true],
|
|
1647
|
+
[profile.baseModel, 275, true],
|
|
1648
|
+
[profile.name, 200, true],
|
|
1649
|
+
[gateway.name, 200, true],
|
|
1650
|
+
[profile.displayName, 200, true],
|
|
1651
|
+
[gateway.display_name, 200, true],
|
|
1652
|
+
[profile.family, 175, true],
|
|
1653
|
+
[gateway.family, 175, true],
|
|
1654
|
+
[profile.description, 100, false],
|
|
1655
|
+
[gateway.description, 100, false],
|
|
1656
|
+
[profile.summary, 50, false],
|
|
1657
|
+
[gateway.summary, 50, false],
|
|
1658
|
+
];
|
|
1659
|
+
const score = Math.max(...fields.map(([value, bonus, allowSubsequence]) => {
|
|
1660
|
+
const fieldScore = fuzzyTextScore(value, needle, allowSubsequence);
|
|
1661
|
+
return fieldScore < 0 ? -1 : fieldScore + bonus;
|
|
1662
|
+
}));
|
|
1663
|
+
return { candidate, index, score };
|
|
1664
|
+
})
|
|
1665
|
+
.filter((entry) => entry.score >= 0)
|
|
1666
|
+
// Gateway order is the stable picker contract. Fuzzy scoring decides
|
|
1667
|
+
// inclusion; it deliberately does not reshuffle rows while the operator
|
|
1668
|
+
// types, which keeps cursor movement predictable.
|
|
1669
|
+
.sort((a, b) => a.index - b.index)
|
|
1670
|
+
.map((entry) => entry.candidate);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1540
1673
|
async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin, stdout, prompt }) {
|
|
1541
1674
|
const lines = makeLineReader(stdin);
|
|
1675
|
+
let query = '';
|
|
1542
1676
|
for (;;) {
|
|
1543
|
-
|
|
1677
|
+
const visibleCandidates = filterModelCandidates(candidates, query);
|
|
1678
|
+
const visibleIds = visibleCandidates.map((candidate) => candidate.id);
|
|
1679
|
+
renderPicker(stdout, visibleIds, selected, prompt, visibleCandidates, {
|
|
1680
|
+
query,
|
|
1681
|
+
total: ordered.length,
|
|
1682
|
+
});
|
|
1544
1683
|
const line = await readPrompt(lines, stdout, stdin.isTTY === true, '> ');
|
|
1545
1684
|
if (line === null) break; // EOF on non-TTY
|
|
1546
1685
|
const cmd = String(line || '').trim();
|
|
@@ -1556,13 +1695,25 @@ async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, st
|
|
|
1556
1695
|
continue;
|
|
1557
1696
|
}
|
|
1558
1697
|
if (cmd === 'q' || cmd === 'quit' || cmd === ':wq') break;
|
|
1698
|
+
if (cmd === '/' || cmd === 'search' || cmd === 'clear search') {
|
|
1699
|
+
query = '';
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
if (cmd.startsWith('/')) {
|
|
1703
|
+
query = cmd.slice(1).trim();
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
if (cmd.startsWith('search ')) {
|
|
1707
|
+
query = cmd.slice('search '.length).trim();
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1559
1710
|
if (cmd.startsWith('toggle ')) {
|
|
1560
1711
|
const idx = Number(cmd.slice('toggle '.length).trim());
|
|
1561
|
-
if (!Number.isInteger(idx) || idx < 1 || idx >
|
|
1712
|
+
if (!Number.isInteger(idx) || idx < 1 || idx > visibleIds.length) {
|
|
1562
1713
|
stdout.write(chalk.red(` x index out of range\n`));
|
|
1563
1714
|
continue;
|
|
1564
1715
|
}
|
|
1565
|
-
const id =
|
|
1716
|
+
const id = visibleIds[idx - 1];
|
|
1566
1717
|
if (selected.has(id)) {
|
|
1567
1718
|
selected.delete(id);
|
|
1568
1719
|
lastOrder = lastOrder.filter((x) => x !== id);
|
|
@@ -1575,8 +1726,8 @@ async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, st
|
|
|
1575
1726
|
const indices = cmd.split(/\s+/).map((s) => Number(s)).filter((n) => Number.isInteger(n));
|
|
1576
1727
|
let changed = false;
|
|
1577
1728
|
for (const n of indices) {
|
|
1578
|
-
if (n < 1 || n >
|
|
1579
|
-
const id =
|
|
1729
|
+
if (n < 1 || n > visibleIds.length) continue;
|
|
1730
|
+
const id = visibleIds[n - 1];
|
|
1580
1731
|
if (selected.has(id)) {
|
|
1581
1732
|
selected.delete(id);
|
|
1582
1733
|
lastOrder = lastOrder.filter((x) => x !== id);
|
|
@@ -1586,7 +1737,7 @@ async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, st
|
|
|
1586
1737
|
}
|
|
1587
1738
|
changed = true;
|
|
1588
1739
|
}
|
|
1589
|
-
if (!changed) stdout.write(chalk.yellow(` ! unrecognised input - try 'all', 'none', 'toggle <i>', or '1 3 5'\n`));
|
|
1740
|
+
if (!changed) stdout.write(chalk.yellow(` ! unrecognised input - try '/query', 'search query', 'all', 'none', 'toggle <i>', or '1 3 5'\n`));
|
|
1590
1741
|
}
|
|
1591
1742
|
const seen = new Set();
|
|
1592
1743
|
return lastOrder.filter((id) => {
|
|
@@ -1621,6 +1772,9 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
|
|
|
1621
1772
|
let scrollTop = 0;
|
|
1622
1773
|
let lastRenderHeight = 0;
|
|
1623
1774
|
let showHelp = false;
|
|
1775
|
+
let query = '';
|
|
1776
|
+
let visibleCandidates = [...candidates];
|
|
1777
|
+
const originalIndices = new Map(candidates.map((candidate, index) => [candidate, index]));
|
|
1624
1778
|
|
|
1625
1779
|
readline.emitKeypressEvents(stdin);
|
|
1626
1780
|
if (typeof stdin.resume === 'function') stdin.resume();
|
|
@@ -1629,8 +1783,18 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
|
|
|
1629
1783
|
const exitState = await new Promise((resolve) => {
|
|
1630
1784
|
const onKey = (str, key) => {
|
|
1631
1785
|
if (!key) return;
|
|
1632
|
-
// Resolve confirmation
|
|
1633
|
-
|
|
1786
|
+
// Resolve confirmation. Escape clears an active search first so a
|
|
1787
|
+
// typo never accidentally exits the picker.
|
|
1788
|
+
if (key.name === 'return') {
|
|
1789
|
+
return finish();
|
|
1790
|
+
}
|
|
1791
|
+
if (key.name === 'escape') {
|
|
1792
|
+
if (query) {
|
|
1793
|
+
query = '';
|
|
1794
|
+
refreshFilter();
|
|
1795
|
+
render();
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1634
1798
|
return finish();
|
|
1635
1799
|
}
|
|
1636
1800
|
if (key.ctrl && key.name === 'c') {
|
|
@@ -1639,27 +1803,36 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
|
|
|
1639
1803
|
lastOrder.length = 0;
|
|
1640
1804
|
return finish();
|
|
1641
1805
|
}
|
|
1642
|
-
if (key.name === 'up'
|
|
1643
|
-
cursor = (cursor - 1 +
|
|
1644
|
-
} else if (key.name === 'down'
|
|
1645
|
-
cursor = (cursor + 1) %
|
|
1646
|
-
} else if (key.name === 'space'
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
selected.
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1806
|
+
if (key.name === 'up' && visibleCandidates.length > 0) {
|
|
1807
|
+
cursor = (cursor - 1 + visibleCandidates.length) % visibleCandidates.length;
|
|
1808
|
+
} else if (key.name === 'down' && visibleCandidates.length > 0) {
|
|
1809
|
+
cursor = (cursor + 1) % visibleCandidates.length;
|
|
1810
|
+
} else if (key.name === 'space') {
|
|
1811
|
+
if (visibleCandidates.length > 0) {
|
|
1812
|
+
const id = visibleCandidates[cursor].id;
|
|
1813
|
+
if (selected.has(id)) {
|
|
1814
|
+
selected.delete(id);
|
|
1815
|
+
lastOrder = lastOrder.filter((x) => x !== id);
|
|
1816
|
+
} else {
|
|
1817
|
+
selected.add(id);
|
|
1818
|
+
lastOrder.push(id);
|
|
1819
|
+
}
|
|
1654
1820
|
}
|
|
1655
|
-
} else if (
|
|
1821
|
+
} else if (key.ctrl && key.name === 'a') {
|
|
1656
1822
|
for (const id of ordered) selected.add(id);
|
|
1657
1823
|
lastOrder = [...ordered];
|
|
1658
|
-
} else if (
|
|
1824
|
+
} else if (key.ctrl && key.name === 'n') {
|
|
1659
1825
|
selected.clear();
|
|
1660
1826
|
lastOrder = [];
|
|
1661
1827
|
} else if (str === '?') {
|
|
1662
1828
|
showHelp = !showHelp;
|
|
1829
|
+
} else if (key.name === 'backspace' || key.name === 'delete') {
|
|
1830
|
+
if (!query) return;
|
|
1831
|
+
query = query.slice(0, -1);
|
|
1832
|
+
refreshFilter();
|
|
1833
|
+
} else if (typeof str === 'string' && str.length === 1 && !key.ctrl && !key.meta && str >= ' ') {
|
|
1834
|
+
query += str;
|
|
1835
|
+
refreshFilter();
|
|
1663
1836
|
} else {
|
|
1664
1837
|
return;
|
|
1665
1838
|
}
|
|
@@ -1688,51 +1861,62 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
|
|
|
1688
1861
|
});
|
|
1689
1862
|
|
|
1690
1863
|
function adjustScroll() {
|
|
1691
|
-
if (
|
|
1864
|
+
if (visibleCandidates.length === 0 || visibleCandidates.length <= VIEWPORT_SIZE) {
|
|
1692
1865
|
scrollTop = 0;
|
|
1693
1866
|
return;
|
|
1694
1867
|
}
|
|
1695
1868
|
if (cursor < scrollTop) scrollTop = cursor;
|
|
1696
1869
|
else if (cursor >= scrollTop + VIEWPORT_SIZE) scrollTop = cursor - VIEWPORT_SIZE + 1;
|
|
1697
1870
|
if (scrollTop < 0) scrollTop = 0;
|
|
1698
|
-
if (scrollTop > Math.max(0,
|
|
1699
|
-
scrollTop = Math.max(0,
|
|
1871
|
+
if (scrollTop > Math.max(0, visibleCandidates.length - VIEWPORT_SIZE)) {
|
|
1872
|
+
scrollTop = Math.max(0, visibleCandidates.length - VIEWPORT_SIZE);
|
|
1700
1873
|
}
|
|
1701
1874
|
}
|
|
1702
1875
|
|
|
1876
|
+
function refreshFilter() {
|
|
1877
|
+
visibleCandidates = filterModelCandidates(candidates, query);
|
|
1878
|
+
cursor = 0;
|
|
1879
|
+
scrollTop = 0;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1703
1882
|
function render() {
|
|
1704
1883
|
const width = String(ordered.length).length;
|
|
1705
1884
|
const columns = typeof stdout.columns === 'number' ? stdout.columns : 80;
|
|
1706
1885
|
const lines = [];
|
|
1707
1886
|
lines.push(`\x1b[K${chalk.bold(`-- ${prompt} --`)}`);
|
|
1887
|
+
lines.push(`\x1b[K${query ? ` Search: ${chalk.bold(query)}` : chalk.dim(' Search: type a model name or ID')}`);
|
|
1708
1888
|
const start = scrollTop;
|
|
1709
|
-
const end = Math.min(
|
|
1889
|
+
const end = Math.min(visibleCandidates.length, start + VIEWPORT_SIZE);
|
|
1710
1890
|
const above = start;
|
|
1711
|
-
const below =
|
|
1891
|
+
const below = visibleCandidates.length - end;
|
|
1712
1892
|
if (above > 0) lines.push(`\x1b[K${chalk.dim(` ⋮ ${above} more above`)}`);
|
|
1713
1893
|
for (let i = start; i < end; i++) {
|
|
1714
|
-
const
|
|
1894
|
+
const candidate = visibleCandidates[i];
|
|
1895
|
+
const originalIndex = originalIndices.get(candidate) ?? i;
|
|
1715
1896
|
const row = fitRow({
|
|
1716
|
-
i,
|
|
1717
|
-
id:
|
|
1718
|
-
profile,
|
|
1897
|
+
i: originalIndex,
|
|
1898
|
+
id: candidate.id,
|
|
1899
|
+
profile: candidate.profile,
|
|
1719
1900
|
width,
|
|
1720
1901
|
isCursor: i === cursor,
|
|
1721
|
-
isSelected: selected.has(
|
|
1902
|
+
isSelected: selected.has(candidate.id),
|
|
1722
1903
|
columns,
|
|
1723
1904
|
});
|
|
1724
1905
|
lines.push(`\x1b[K${row}`);
|
|
1725
1906
|
}
|
|
1907
|
+
if (visibleCandidates.length === 0) lines.push(`\x1b[K${chalk.yellow(' No models match your search.')}`);
|
|
1726
1908
|
if (below > 0) lines.push(`\x1b[K${chalk.dim(` ⋮ ${below} more below`)}`);
|
|
1727
|
-
lines.push(`\x1b[K${chalk.dim(` ${selected.size}/${ordered.length} selected. ↑/↓ move · space toggle ·
|
|
1728
|
-
if (showHelp) lines.push(`\x1b[K${chalk.dim(` Extra:
|
|
1909
|
+
lines.push(`\x1b[K${chalk.dim(` ${selected.size}/${ordered.length} selected · ${visibleCandidates.length}/${ordered.length} shown. ↑/↓ move · space toggle · enter confirm.`)}`);
|
|
1910
|
+
if (showHelp) lines.push(`\x1b[K${chalk.dim(` Extra: type to search · backspace edit · esc clear/confirm · ctrl+a all · ctrl+n none · ? help.`)}`);
|
|
1729
1911
|
lines.push(`\x1b[K`);
|
|
1730
1912
|
|
|
1913
|
+
const contentHeight = lines.length;
|
|
1914
|
+
while (lines.length < lastRenderHeight) lines.push('\x1b[K');
|
|
1731
1915
|
if (lastRenderHeight > 0) {
|
|
1732
1916
|
stdout.write(cursorUp(lastRenderHeight));
|
|
1733
1917
|
}
|
|
1734
1918
|
stdout.write(lines.join('\n'));
|
|
1735
|
-
lastRenderHeight =
|
|
1919
|
+
lastRenderHeight = Math.max(lastRenderHeight, contentHeight);
|
|
1736
1920
|
}
|
|
1737
1921
|
}
|
|
1738
1922
|
|
|
@@ -1968,17 +2152,22 @@ async function readPrompt(lineReader, out$, isTTY, prefix) {
|
|
|
1968
2152
|
return r.value;
|
|
1969
2153
|
}
|
|
1970
2154
|
|
|
1971
|
-
function renderPicker(out, ordered, selected, prompt, candidates = []) {
|
|
2155
|
+
function renderPicker(out, ordered, selected, prompt, candidates = [], { query = '', total = ordered.length } = {}) {
|
|
1972
2156
|
out.write('\n' + chalk.bold(`-- ${prompt} --`) + '\n');
|
|
1973
|
-
|
|
2157
|
+
out.write(query
|
|
2158
|
+
? ` Search: ${chalk.bold(query)} ${chalk.dim(`(${ordered.length}/${total} shown)`)}\n`
|
|
2159
|
+
: chalk.dim(` Search: /query or search query (${ordered.length}/${total} shown)\n`));
|
|
2160
|
+
const width = String(Math.max(ordered.length, 1)).length;
|
|
2161
|
+
const candidatesById = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
1974
2162
|
for (let i = 0; i < ordered.length; i++) {
|
|
1975
2163
|
const id = ordered[i];
|
|
1976
2164
|
const mark = selected.has(id) ? chalk.green('[x]') : '[ ]';
|
|
1977
2165
|
const idx = String(i + 1).padStart(width, ' ');
|
|
1978
|
-
const profile =
|
|
2166
|
+
const profile = candidatesById.get(id)?.profile;
|
|
1979
2167
|
out.write(` ${mark} ${chalk.dim(idx + '.')} ${id}${chalk.dim(` [${capabilityLabel(profile)}]`)}\n`);
|
|
1980
2168
|
}
|
|
1981
|
-
out.write(chalk.
|
|
2169
|
+
if (ordered.length === 0) out.write(chalk.yellow(' No models match your search.\n'));
|
|
2170
|
+
out.write(chalk.dim(`\n ${selected.size}/${total} selected. Numbers target shown rows; '/', 'all', 'none', or Enter to confirm.\n`));
|
|
1982
2171
|
}
|
|
1983
2172
|
|
|
1984
2173
|
function askLine(rl, prefix) {
|
|
@@ -1994,7 +2183,7 @@ function showHelp() {
|
|
|
1994
2183
|
bizar models - User-controlled model picker
|
|
1995
2184
|
|
|
1996
2185
|
Usage:
|
|
1997
|
-
bizar models Interactive picker
|
|
2186
|
+
bizar models Interactive searchable picker
|
|
1998
2187
|
bizar models --list Print candidate IDs, one per line
|
|
1999
2188
|
bizar models --set a,b,c Persist the comma-separated IDs to userSelected
|
|
2000
2189
|
bizar models --clear Remove userSelected; use the first enabled configured-tier model
|
|
@@ -2007,6 +2196,12 @@ function showHelp() {
|
|
|
2007
2196
|
configured-tier candidates. Every dispatch receives an explicit model;
|
|
2008
2197
|
Bizar never inherits an unconfigured provider default.
|
|
2009
2198
|
|
|
2199
|
+
Picker search: in a TTY, type a model ID or name to fuzzy-filter, use
|
|
2200
|
+
Backspace to edit, and Escape to clear the query (or confirm when clear).
|
|
2201
|
+
Arrow keys move, Space toggles, Enter confirms, and Ctrl+A/Ctrl+N select
|
|
2202
|
+
all or none. In line mode, use /query or "search query"; / clears search,
|
|
2203
|
+
and numeric choices target the currently shown rows.
|
|
2204
|
+
|
|
2010
2205
|
Post-confirm status screen (interactive only): after the picker saves a
|
|
2011
2206
|
selection, every confirmed id is reported on its own row with one of:
|
|
2012
2207
|
|
|
@@ -2234,12 +2429,9 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
2234
2429
|
// We do not short-circuit here — the code below falls into the
|
|
2235
2430
|
// `wantList || isDeprecatedAlias` branch and prints candidate IDs.
|
|
2236
2431
|
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
// never looked at the result. Move the fetch to AFTER picker
|
|
2241
|
-
// confirmation (see the interactive branch below); `--list` and
|
|
2242
|
-
// `bizar model` now skip the fetch entirely.
|
|
2432
|
+
// Catalog metadata is an interactive presentation dependency. `--list`,
|
|
2433
|
+
// `--set`, and the deprecated list alias remain gateway-only and never
|
|
2434
|
+
// contact Models.dev.
|
|
2243
2435
|
let candidates;
|
|
2244
2436
|
let modelsDev = { status: 'skipped', matched: 0, total: 0, source: MODELS_DEV_CATALOG_URL, note: 'lazy fetch — --list does not contact models.dev' };
|
|
2245
2437
|
try {
|
|
@@ -2301,37 +2493,38 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
2301
2493
|
|
|
2302
2494
|
const router = loadRouter(routerPath);
|
|
2303
2495
|
const { models: current } = currentSelection(router);
|
|
2496
|
+
const previousProfiles = router?.userSelected?.profiles || {};
|
|
2304
2497
|
// 10.19.9 Phase 3: capture a snapshot of userSelected.models BEFORE
|
|
2305
2498
|
// applyModels overwrites the block, so the post-confirm status screen
|
|
2306
2499
|
// can classify re-confirmed picks as `preexisting` (⤳) instead of
|
|
2307
2500
|
// `fresh` (✔). `current` is read once and shared with pickModelsFn;
|
|
2308
2501
|
// we wrap it in a Set so classifyPickStatus can use `.has(id)`.
|
|
2309
2502
|
const preExisting = new Set(current);
|
|
2310
|
-
const picked = await pickModelsFn({ candidates, current });
|
|
2311
|
-
|
|
2312
|
-
// Phase 2 (10.19.8): the Models.dev catalog fetch moves HERE — only
|
|
2313
|
-
// AFTER the operator has actually confirmed picks. Per-id enrichment
|
|
2314
|
-
// runs in parallel with bounded concurrency (8 workers) and a per-id
|
|
2315
|
-
// timeout (3000ms); per-id failures fall back to the Phase 1
|
|
2316
|
-
// `_gateway.name` contract.
|
|
2317
2503
|
const fetchModelsDevCatalogFn = deps.fetchModelsDevCatalog || fetchModelsDevCatalog;
|
|
2318
|
-
// Test callers that inject the base fetch intentionally stay offline; the
|
|
2319
|
-
// production path fetches both Models.dev datasets concurrently.
|
|
2320
2504
|
const fetchProviderCatalogFn = deps.fetchProviderCatalog
|
|
2321
2505
|
|| (deps.fetchModelsDevCatalog ? undefined : fetchProviderCatalog);
|
|
2506
|
+
// Fetch both catalogs once, concurrently, before rendering. Reuse this map
|
|
2507
|
+
// after confirmation so the picker and persisted settings see identical
|
|
2508
|
+
// metadata and confirmation never causes a second network round trip.
|
|
2322
2509
|
const enrichment = await enrichPicksByMetadata({
|
|
2323
2510
|
candidates,
|
|
2324
|
-
pickedIds:
|
|
2511
|
+
pickedIds: candidates.map((candidate) => candidate.id),
|
|
2325
2512
|
fetchFn: fetchModelsDevCatalogFn,
|
|
2326
2513
|
providerFetchFn: fetchProviderCatalogFn,
|
|
2327
2514
|
});
|
|
2328
2515
|
const profilesMap = enrichment.profiles;
|
|
2516
|
+
const enrichedCandidates = candidates.map((candidate) => {
|
|
2517
|
+
const fresh = profilesMap.get(candidate.id);
|
|
2518
|
+
const cached = previousProfiles[candidate.id];
|
|
2519
|
+
const profile = fresh?.metadata?.source === 'models.dev' ? fresh : (cached || fresh || null);
|
|
2520
|
+
return { ...candidate, profile, contextWindow: profile?.limits?.contextTokens ?? null };
|
|
2521
|
+
});
|
|
2522
|
+
const picked = await pickModelsFn({ candidates: enrichedCandidates, current });
|
|
2329
2523
|
const modelsDevStatus = enrichment.modelsDev && Object.keys(enrichment.modelsDev).length > 0
|
|
2330
|
-
? { status: 'ok', source: MODELS_DEV_CATALOG_URL, matched:
|
|
2524
|
+
? { status: 'ok', source: MODELS_DEV_CATALOG_URL, matched: picked.filter((id) => profilesMap.get(id)?.metadata?.source === 'models.dev').length, total: picked.length }
|
|
2331
2525
|
: { status: 'unavailable', source: MODELS_DEV_CATALOG_URL, matched: 0, total: picked.length, note: 'wholesale fetch failed; per-id enrichment degraded to _gateway.name fallback' };
|
|
2332
2526
|
|
|
2333
2527
|
if (picked.length === 0) {
|
|
2334
|
-
const previousProfiles = loadRouter(routerPath)?.userSelected?.profiles || {};
|
|
2335
2528
|
const block = applyModels({ routerPath, models: [], source: 'live-pick' });
|
|
2336
2529
|
// Clear Claude Code modelOverrides + modelPicker when the picker is
|
|
2337
2530
|
// emptied so the session no longer claims to recognise removed IDs.
|
|
@@ -2356,7 +2549,13 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
2356
2549
|
const profiles = {};
|
|
2357
2550
|
for (const id of picked) {
|
|
2358
2551
|
tierHints[id] = defaultTierHint(id);
|
|
2359
|
-
const
|
|
2552
|
+
const fresh = profilesMap.get(id);
|
|
2553
|
+
// A transient catalog miss must not erase a previously good profile.
|
|
2554
|
+
// Gateway fallback data remains useful for new models, but cached
|
|
2555
|
+
// Models.dev/operator facts take precedence when already present.
|
|
2556
|
+
const profile = fresh?.metadata?.source === 'models.dev'
|
|
2557
|
+
? fresh
|
|
2558
|
+
: (previousProfiles[id] || fresh);
|
|
2360
2559
|
if (profile) profiles[id] = profile;
|
|
2361
2560
|
}
|
|
2362
2561
|
// F-191 / 10.19.2 — stale-ID detection: the picker shows candidates
|
|
@@ -2366,7 +2565,6 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
2366
2565
|
// Code still surfaces the unrecognized_model diagnostic.
|
|
2367
2566
|
const liveIds = candidates.map((c) => c.id);
|
|
2368
2567
|
const partition = partitionStalePicks({ liveIds, pickedIds: picked });
|
|
2369
|
-
const previousProfiles = loadRouter(routerPath)?.userSelected?.profiles || {};
|
|
2370
2568
|
const block = applyModels({ routerPath, models: picked, tierHints, profiles, source: 'live-pick' });
|
|
2371
2569
|
if (partition.staleIds.length > 0) {
|
|
2372
2570
|
block.staleIds = partition.staleIds;
|
|
@@ -2388,7 +2586,7 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
2388
2586
|
// equivalent data shape.
|
|
2389
2587
|
const statusResult = renderPickStatusScreen({
|
|
2390
2588
|
picked,
|
|
2391
|
-
profiles
|
|
2589
|
+
profiles,
|
|
2392
2590
|
preExisting,
|
|
2393
2591
|
out: { write: () => true },
|
|
2394
2592
|
isTTY: false,
|
|
@@ -2426,7 +2624,7 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
2426
2624
|
// operator feedback there.
|
|
2427
2625
|
const statusResult = renderPickStatusScreen({
|
|
2428
2626
|
picked,
|
|
2429
|
-
profiles
|
|
2627
|
+
profiles,
|
|
2430
2628
|
preExisting,
|
|
2431
2629
|
out: process.stdout,
|
|
2432
2630
|
isTTY: !!process.stdout.isTTY,
|