@polderlabs/bizar 10.23.6 → 10.23.7

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.
@@ -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.values(value)) {
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 || {})) add(modelId, model);
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 wanted = normalizedModelIdentity(gatewayId);
314
- const matches = all.filter((entry) => {
315
- const found = normalizedModelIdentity(entry.id);
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
- * Strip a `<provider>/` prefix from a model id. Used as a last-ditch catalog
369
- * lookup key when the gateway reports the model under a wrapper namespace
370
- * (e.g. `claude-minimax/MiniMax-M3`) and the models.dev catalog uses the bare
371
- * form (`minimax/MiniMax-M3`).
372
- *
373
- * @param {string} id
374
- * @returns {string}
375
- */
376
- function stripProvider(id) {
377
- const raw = String(id || '').trim();
378
- const slash = raw.indexOf('/');
379
- return slash >= 0 ? raw.slice(slash + 1) : raw;
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
- * Phase 2 lazy enrichment. The interactive picker path used to call
384
- * `fetchModelsDevCatalog` BEFORE the picker opened, paying the round-trip
385
- * on every `--list`/`--set` (which never even consulted the catalog) and
386
- * on every interactive session that the user then aborted.
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.
387
432
  *
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.
392
- *
393
- * Concurrency: per-id lookups run in parallel with a bounded worker pool
394
- * (`concurrency`, default 8) and a per-id timeout (`timeoutMs`, default
395
- * 3000ms). On per-id timeout or lookup failure, we fall back to the
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) => Promise.race([
423
- fn({}),
424
- new Promise((_, reject) => setTimeout(() => reject(new Error(`enrichPicksByMetadata: ${label} fetch timeout`)), timeoutMs)),
425
- ]).catch(() => ({}));
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.race([
460
- // Each "job" resolves immediately with the profile lookup; the
461
- // race is a defense-in-depth measure so a misbehaving fetchFn
462
- // cannot stall the worker past `timeoutMs`.
463
- Promise.resolve().then(() => {
464
- const lower = String(id).toLowerCase();
465
- const baseMatch = catalogMap.get(lower)
466
- || catalogMap.get(stripProvider(id).toLowerCase());
467
- const providerMatch = providerMap.get(lower)
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 (profile.capabilities.reasoning) caps.push('reasoning');
530
- if (profile.capabilities.toolCall) caps.push('tools');
531
- if (profile.capabilities.structuredOutput) caps.push('structured');
532
- if (profile.capabilities.inputModalities.some((m) => m !== 'text')) caps.push('multimodal');
533
- if (profile.limits.contextTokens) caps.push(formatContextTokens(profile.limits.contextTokens));
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.filter((id) => profiles[id]).map((id) => [id, profiles[id]])),
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
@@ -1490,10 +1506,12 @@ export function explainSelection({ routerPath, role, requirements = {} } = {}) {
1490
1506
  * Interactive multi-select picker. Pure function over streams — testable.
1491
1507
  *
1492
1508
  * Branching: when stdin is a TTY with raw-mode support, the picker drives a
1493
- * keypress-driven checklist (arrow keys / j-k / space / a / n / enter / q /
1494
- * esc / ?). When stdin is not a TTY (pipes, CI, tests) the picker falls
1509
+ * keypress-driven checklist with direct type-to-search (arrow keys / space /
1510
+ * Ctrl+A / Ctrl+N / enter / esc / backspace / ?). When stdin is not a TTY (pipes, CI,
1511
+ * tests) the picker falls
1495
1512
  * through to a line-mode loop that accepts a space-separated index list,
1496
- * `all`, `none`, `toggle <i>`, an empty line (confirm), or `q` (quit). Both
1513
+ * `all`, `none`, `toggle <i>`, `/query`, `search query`, an empty line
1514
+ * (confirm), or `q` (quit). Both
1497
1515
  * branches return the chosen IDs in the user's most-recent selection order,
1498
1516
  * so external callers and existing tests see a single `Promise<string[]>`.
1499
1517
  *
@@ -1537,10 +1555,112 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
1537
1555
  }
1538
1556
  }
1539
1557
 
1558
+ function normalizeSearchText(value) {
1559
+ return String(value || '')
1560
+ .toLowerCase()
1561
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
1562
+ .trim()
1563
+ .replace(/\s+/g, ' ');
1564
+ }
1565
+
1566
+ /**
1567
+ * Score a fuzzy query against one text field. Exact, prefix, token-prefix,
1568
+ * and substring matches lead; ordered subsequences remain useful for compact
1569
+ * queries such as `gpt56l`. A negative score means no match.
1570
+ */
1571
+ function fuzzyTextScore(value, query, allowSubsequence = true) {
1572
+ const text = normalizeSearchText(value);
1573
+ const needle = normalizeSearchText(query);
1574
+ if (!needle) return 0;
1575
+ if (!text) return -1;
1576
+ if (text === needle) return 10_000;
1577
+ if (text.startsWith(needle)) return 9_000 - (text.length - needle.length);
1578
+
1579
+ const tokenIndex = text.split(' ').findIndex((token) => token.startsWith(needle));
1580
+ if (tokenIndex >= 0) return 8_000 - tokenIndex * 10;
1581
+
1582
+ const substringIndex = text.indexOf(needle);
1583
+ if (substringIndex >= 0) return 7_000 - substringIndex;
1584
+
1585
+ // One- and two-character subsequences are too permissive across model
1586
+ // descriptions. Short searches must be contiguous.
1587
+ if (!allowSubsequence || needle.replace(/\s/g, '').length < 3) return -1;
1588
+
1589
+ let textIndex = 0;
1590
+ let first = -1;
1591
+ let previous = -1;
1592
+ let gaps = 0;
1593
+ let boundaries = 0;
1594
+ for (const char of needle) {
1595
+ if (char === ' ') continue;
1596
+ const found = text.indexOf(char, textIndex);
1597
+ if (found < 0) return -1;
1598
+ if (first < 0) first = found;
1599
+ if (previous >= 0) gaps += found - previous - 1;
1600
+ if (found === 0 || text[found - 1] === ' ') boundaries++;
1601
+ previous = found;
1602
+ textIndex = found + 1;
1603
+ }
1604
+ const compactNeedleLength = needle.replace(/\s/g, '').length;
1605
+ if (gaps > Math.max(8, compactNeedleLength * 2)) return -1;
1606
+ return 4_000 + boundaries * 25 - first * 2 - gaps;
1607
+ }
1608
+
1609
+ /**
1610
+ * Pure fuzzy filter used by both picker modes. Match quality determines
1611
+ * inclusion while gateway order remains stable as the query changes.
1612
+ *
1613
+ * @param {Array<{id: string, profile?: object, _gateway?: object}>} candidates
1614
+ * @param {string} query
1615
+ * @returns {Array<{id: string, profile?: object, _gateway?: object}>}
1616
+ */
1617
+ export function filterModelCandidates(candidates, query) {
1618
+ if (!Array.isArray(candidates)) return [];
1619
+ const needle = normalizeSearchText(query);
1620
+ if (!needle) return [...candidates];
1621
+
1622
+ return candidates
1623
+ .map((candidate, index) => {
1624
+ const profile = candidate?.profile || {};
1625
+ const gateway = candidate?._gateway || {};
1626
+ const fields = [
1627
+ [candidate?.id, 300, true],
1628
+ [profile.baseModel, 275, true],
1629
+ [profile.name, 200, true],
1630
+ [gateway.name, 200, true],
1631
+ [profile.displayName, 200, true],
1632
+ [gateway.display_name, 200, true],
1633
+ [profile.family, 175, true],
1634
+ [gateway.family, 175, true],
1635
+ [profile.description, 100, false],
1636
+ [gateway.description, 100, false],
1637
+ [profile.summary, 50, false],
1638
+ [gateway.summary, 50, false],
1639
+ ];
1640
+ const score = Math.max(...fields.map(([value, bonus, allowSubsequence]) => {
1641
+ const fieldScore = fuzzyTextScore(value, needle, allowSubsequence);
1642
+ return fieldScore < 0 ? -1 : fieldScore + bonus;
1643
+ }));
1644
+ return { candidate, index, score };
1645
+ })
1646
+ .filter((entry) => entry.score >= 0)
1647
+ // Gateway order is the stable picker contract. Fuzzy scoring decides
1648
+ // inclusion; it deliberately does not reshuffle rows while the operator
1649
+ // types, which keeps cursor movement predictable.
1650
+ .sort((a, b) => a.index - b.index)
1651
+ .map((entry) => entry.candidate);
1652
+ }
1653
+
1540
1654
  async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin, stdout, prompt }) {
1541
1655
  const lines = makeLineReader(stdin);
1656
+ let query = '';
1542
1657
  for (;;) {
1543
- renderPicker(stdout, ordered, selected, prompt, candidates);
1658
+ const visibleCandidates = filterModelCandidates(candidates, query);
1659
+ const visibleIds = visibleCandidates.map((candidate) => candidate.id);
1660
+ renderPicker(stdout, visibleIds, selected, prompt, visibleCandidates, {
1661
+ query,
1662
+ total: ordered.length,
1663
+ });
1544
1664
  const line = await readPrompt(lines, stdout, stdin.isTTY === true, '> ');
1545
1665
  if (line === null) break; // EOF on non-TTY
1546
1666
  const cmd = String(line || '').trim();
@@ -1556,13 +1676,25 @@ async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, st
1556
1676
  continue;
1557
1677
  }
1558
1678
  if (cmd === 'q' || cmd === 'quit' || cmd === ':wq') break;
1679
+ if (cmd === '/' || cmd === 'search' || cmd === 'clear search') {
1680
+ query = '';
1681
+ continue;
1682
+ }
1683
+ if (cmd.startsWith('/')) {
1684
+ query = cmd.slice(1).trim();
1685
+ continue;
1686
+ }
1687
+ if (cmd.startsWith('search ')) {
1688
+ query = cmd.slice('search '.length).trim();
1689
+ continue;
1690
+ }
1559
1691
  if (cmd.startsWith('toggle ')) {
1560
1692
  const idx = Number(cmd.slice('toggle '.length).trim());
1561
- if (!Number.isInteger(idx) || idx < 1 || idx > ordered.length) {
1693
+ if (!Number.isInteger(idx) || idx < 1 || idx > visibleIds.length) {
1562
1694
  stdout.write(chalk.red(` x index out of range\n`));
1563
1695
  continue;
1564
1696
  }
1565
- const id = ordered[idx - 1];
1697
+ const id = visibleIds[idx - 1];
1566
1698
  if (selected.has(id)) {
1567
1699
  selected.delete(id);
1568
1700
  lastOrder = lastOrder.filter((x) => x !== id);
@@ -1575,8 +1707,8 @@ async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, st
1575
1707
  const indices = cmd.split(/\s+/).map((s) => Number(s)).filter((n) => Number.isInteger(n));
1576
1708
  let changed = false;
1577
1709
  for (const n of indices) {
1578
- if (n < 1 || n > ordered.length) continue;
1579
- const id = ordered[n - 1];
1710
+ if (n < 1 || n > visibleIds.length) continue;
1711
+ const id = visibleIds[n - 1];
1580
1712
  if (selected.has(id)) {
1581
1713
  selected.delete(id);
1582
1714
  lastOrder = lastOrder.filter((x) => x !== id);
@@ -1586,7 +1718,7 @@ async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, st
1586
1718
  }
1587
1719
  changed = true;
1588
1720
  }
1589
- if (!changed) stdout.write(chalk.yellow(` ! unrecognised input - try 'all', 'none', 'toggle <i>', or '1 3 5'\n`));
1721
+ if (!changed) stdout.write(chalk.yellow(` ! unrecognised input - try '/query', 'search query', 'all', 'none', 'toggle <i>', or '1 3 5'\n`));
1590
1722
  }
1591
1723
  const seen = new Set();
1592
1724
  return lastOrder.filter((id) => {
@@ -1621,6 +1753,9 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
1621
1753
  let scrollTop = 0;
1622
1754
  let lastRenderHeight = 0;
1623
1755
  let showHelp = false;
1756
+ let query = '';
1757
+ let visibleCandidates = [...candidates];
1758
+ const originalIndices = new Map(candidates.map((candidate, index) => [candidate, index]));
1624
1759
 
1625
1760
  readline.emitKeypressEvents(stdin);
1626
1761
  if (typeof stdin.resume === 'function') stdin.resume();
@@ -1629,8 +1764,18 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
1629
1764
  const exitState = await new Promise((resolve) => {
1630
1765
  const onKey = (str, key) => {
1631
1766
  if (!key) return;
1632
- // Resolve confirmation
1633
- if (key.name === 'return' || str === 'q' || key.name === 'escape') {
1767
+ // Resolve confirmation. Escape clears an active search first so a
1768
+ // typo never accidentally exits the picker.
1769
+ if (key.name === 'return') {
1770
+ return finish();
1771
+ }
1772
+ if (key.name === 'escape') {
1773
+ if (query) {
1774
+ query = '';
1775
+ refreshFilter();
1776
+ render();
1777
+ return;
1778
+ }
1634
1779
  return finish();
1635
1780
  }
1636
1781
  if (key.ctrl && key.name === 'c') {
@@ -1639,27 +1784,36 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
1639
1784
  lastOrder.length = 0;
1640
1785
  return finish();
1641
1786
  }
1642
- if (key.name === 'up' || str === 'k') {
1643
- cursor = (cursor - 1 + ordered.length) % ordered.length;
1644
- } else if (key.name === 'down' || str === 'j') {
1645
- cursor = (cursor + 1) % ordered.length;
1646
- } else if (key.name === 'space' || str === 'x') {
1647
- const id = ordered[cursor];
1648
- if (selected.has(id)) {
1649
- selected.delete(id);
1650
- lastOrder = lastOrder.filter((x) => x !== id);
1651
- } else {
1652
- selected.add(id);
1653
- lastOrder.push(id);
1787
+ if (key.name === 'up' && visibleCandidates.length > 0) {
1788
+ cursor = (cursor - 1 + visibleCandidates.length) % visibleCandidates.length;
1789
+ } else if (key.name === 'down' && visibleCandidates.length > 0) {
1790
+ cursor = (cursor + 1) % visibleCandidates.length;
1791
+ } else if (key.name === 'space') {
1792
+ if (visibleCandidates.length > 0) {
1793
+ const id = visibleCandidates[cursor].id;
1794
+ if (selected.has(id)) {
1795
+ selected.delete(id);
1796
+ lastOrder = lastOrder.filter((x) => x !== id);
1797
+ } else {
1798
+ selected.add(id);
1799
+ lastOrder.push(id);
1800
+ }
1654
1801
  }
1655
- } else if (str === 'a') {
1802
+ } else if (key.ctrl && key.name === 'a') {
1656
1803
  for (const id of ordered) selected.add(id);
1657
1804
  lastOrder = [...ordered];
1658
- } else if (str === 'n') {
1805
+ } else if (key.ctrl && key.name === 'n') {
1659
1806
  selected.clear();
1660
1807
  lastOrder = [];
1661
1808
  } else if (str === '?') {
1662
1809
  showHelp = !showHelp;
1810
+ } else if (key.name === 'backspace' || key.name === 'delete') {
1811
+ if (!query) return;
1812
+ query = query.slice(0, -1);
1813
+ refreshFilter();
1814
+ } else if (typeof str === 'string' && str.length === 1 && !key.ctrl && !key.meta && str >= ' ') {
1815
+ query += str;
1816
+ refreshFilter();
1663
1817
  } else {
1664
1818
  return;
1665
1819
  }
@@ -1688,51 +1842,62 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
1688
1842
  });
1689
1843
 
1690
1844
  function adjustScroll() {
1691
- if (ordered.length <= VIEWPORT_SIZE) {
1845
+ if (visibleCandidates.length === 0 || visibleCandidates.length <= VIEWPORT_SIZE) {
1692
1846
  scrollTop = 0;
1693
1847
  return;
1694
1848
  }
1695
1849
  if (cursor < scrollTop) scrollTop = cursor;
1696
1850
  else if (cursor >= scrollTop + VIEWPORT_SIZE) scrollTop = cursor - VIEWPORT_SIZE + 1;
1697
1851
  if (scrollTop < 0) scrollTop = 0;
1698
- if (scrollTop > Math.max(0, ordered.length - VIEWPORT_SIZE)) {
1699
- scrollTop = Math.max(0, ordered.length - VIEWPORT_SIZE);
1852
+ if (scrollTop > Math.max(0, visibleCandidates.length - VIEWPORT_SIZE)) {
1853
+ scrollTop = Math.max(0, visibleCandidates.length - VIEWPORT_SIZE);
1700
1854
  }
1701
1855
  }
1702
1856
 
1857
+ function refreshFilter() {
1858
+ visibleCandidates = filterModelCandidates(candidates, query);
1859
+ cursor = 0;
1860
+ scrollTop = 0;
1861
+ }
1862
+
1703
1863
  function render() {
1704
1864
  const width = String(ordered.length).length;
1705
1865
  const columns = typeof stdout.columns === 'number' ? stdout.columns : 80;
1706
1866
  const lines = [];
1707
1867
  lines.push(`\x1b[K${chalk.bold(`-- ${prompt} --`)}`);
1868
+ lines.push(`\x1b[K${query ? ` Search: ${chalk.bold(query)}` : chalk.dim(' Search: type a model name or ID')}`);
1708
1869
  const start = scrollTop;
1709
- const end = Math.min(ordered.length, start + VIEWPORT_SIZE);
1870
+ const end = Math.min(visibleCandidates.length, start + VIEWPORT_SIZE);
1710
1871
  const above = start;
1711
- const below = ordered.length - end;
1872
+ const below = visibleCandidates.length - end;
1712
1873
  if (above > 0) lines.push(`\x1b[K${chalk.dim(` ⋮ ${above} more above`)}`);
1713
1874
  for (let i = start; i < end; i++) {
1714
- const profile = candidates.find((candidate) => candidate.id === ordered[i])?.profile;
1875
+ const candidate = visibleCandidates[i];
1876
+ const originalIndex = originalIndices.get(candidate) ?? i;
1715
1877
  const row = fitRow({
1716
- i,
1717
- id: ordered[i],
1718
- profile,
1878
+ i: originalIndex,
1879
+ id: candidate.id,
1880
+ profile: candidate.profile,
1719
1881
  width,
1720
1882
  isCursor: i === cursor,
1721
- isSelected: selected.has(ordered[i]),
1883
+ isSelected: selected.has(candidate.id),
1722
1884
  columns,
1723
1885
  });
1724
1886
  lines.push(`\x1b[K${row}`);
1725
1887
  }
1888
+ if (visibleCandidates.length === 0) lines.push(`\x1b[K${chalk.yellow(' No models match your search.')}`);
1726
1889
  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 · a all · n none · enter confirm.`)}`);
1728
- if (showHelp) lines.push(`\x1b[K${chalk.dim(` Extra: j/k · x toggle · q/esc confirm · ? help.`)}`);
1890
+ lines.push(`\x1b[K${chalk.dim(` ${selected.size}/${ordered.length} selected · ${visibleCandidates.length}/${ordered.length} shown. ↑/↓ move · space toggle · enter confirm.`)}`);
1891
+ 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
1892
  lines.push(`\x1b[K`);
1730
1893
 
1894
+ const contentHeight = lines.length;
1895
+ while (lines.length < lastRenderHeight) lines.push('\x1b[K');
1731
1896
  if (lastRenderHeight > 0) {
1732
1897
  stdout.write(cursorUp(lastRenderHeight));
1733
1898
  }
1734
1899
  stdout.write(lines.join('\n'));
1735
- lastRenderHeight = lines.length;
1900
+ lastRenderHeight = Math.max(lastRenderHeight, contentHeight);
1736
1901
  }
1737
1902
  }
1738
1903
 
@@ -1968,17 +2133,22 @@ async function readPrompt(lineReader, out$, isTTY, prefix) {
1968
2133
  return r.value;
1969
2134
  }
1970
2135
 
1971
- function renderPicker(out, ordered, selected, prompt, candidates = []) {
2136
+ function renderPicker(out, ordered, selected, prompt, candidates = [], { query = '', total = ordered.length } = {}) {
1972
2137
  out.write('\n' + chalk.bold(`-- ${prompt} --`) + '\n');
1973
- const width = String(ordered.length).length;
2138
+ out.write(query
2139
+ ? ` Search: ${chalk.bold(query)} ${chalk.dim(`(${ordered.length}/${total} shown)`)}\n`
2140
+ : chalk.dim(` Search: /query or search query (${ordered.length}/${total} shown)\n`));
2141
+ const width = String(Math.max(ordered.length, 1)).length;
2142
+ const candidatesById = new Map(candidates.map((candidate) => [candidate.id, candidate]));
1974
2143
  for (let i = 0; i < ordered.length; i++) {
1975
2144
  const id = ordered[i];
1976
2145
  const mark = selected.has(id) ? chalk.green('[x]') : '[ ]';
1977
2146
  const idx = String(i + 1).padStart(width, ' ');
1978
- const profile = candidates.find((candidate) => candidate.id === id)?.profile;
2147
+ const profile = candidatesById.get(id)?.profile;
1979
2148
  out.write(` ${mark} ${chalk.dim(idx + '.')} ${id}${chalk.dim(` [${capabilityLabel(profile)}]`)}\n`);
1980
2149
  }
1981
- out.write(chalk.dim(`\n ${selected.size}/${ordered.length} selected. Type numbers to toggle, 'all', 'none', or Enter to confirm.\n`));
2150
+ if (ordered.length === 0) out.write(chalk.yellow(' No models match your search.\n'));
2151
+ out.write(chalk.dim(`\n ${selected.size}/${total} selected. Numbers target shown rows; '/', 'all', 'none', or Enter to confirm.\n`));
1982
2152
  }
1983
2153
 
1984
2154
  function askLine(rl, prefix) {
@@ -1994,7 +2164,7 @@ function showHelp() {
1994
2164
  bizar models - User-controlled model picker
1995
2165
 
1996
2166
  Usage:
1997
- bizar models Interactive picker (TTY: arrow keys / space / enter; pipe: line-mode)
2167
+ bizar models Interactive searchable picker
1998
2168
  bizar models --list Print candidate IDs, one per line
1999
2169
  bizar models --set a,b,c Persist the comma-separated IDs to userSelected
2000
2170
  bizar models --clear Remove userSelected; use the first enabled configured-tier model
@@ -2007,6 +2177,12 @@ function showHelp() {
2007
2177
  configured-tier candidates. Every dispatch receives an explicit model;
2008
2178
  Bizar never inherits an unconfigured provider default.
2009
2179
 
2180
+ Picker search: in a TTY, type a model ID or name to fuzzy-filter, use
2181
+ Backspace to edit, and Escape to clear the query (or confirm when clear).
2182
+ Arrow keys move, Space toggles, Enter confirms, and Ctrl+A/Ctrl+N select
2183
+ all or none. In line mode, use /query or "search query"; / clears search,
2184
+ and numeric choices target the currently shown rows.
2185
+
2010
2186
  Post-confirm status screen (interactive only): after the picker saves a
2011
2187
  selection, every confirmed id is reported on its own row with one of:
2012
2188
 
@@ -2234,12 +2410,9 @@ export async function run(name, args, isHelpRequest, deps = {}) {
2234
2410
  // We do not short-circuit here — the code below falls into the
2235
2411
  // `wantList || isDeprecatedAlias` branch and prints candidate IDs.
2236
2412
 
2237
- // Phase 2 (10.19.8): the Models.dev catalog fetch used to happen HERE,
2238
- // before any flag was inspected. That paid the round-trip on every
2239
- // `--list`, every `--set`, every aborted picker, and every CI run that
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.
2413
+ // Catalog metadata is an interactive presentation dependency. `--list`,
2414
+ // `--set`, and the deprecated list alias remain gateway-only and never
2415
+ // contact Models.dev.
2243
2416
  let candidates;
2244
2417
  let modelsDev = { status: 'skipped', matched: 0, total: 0, source: MODELS_DEV_CATALOG_URL, note: 'lazy fetch — --list does not contact models.dev' };
2245
2418
  try {
@@ -2301,37 +2474,38 @@ export async function run(name, args, isHelpRequest, deps = {}) {
2301
2474
 
2302
2475
  const router = loadRouter(routerPath);
2303
2476
  const { models: current } = currentSelection(router);
2477
+ const previousProfiles = router?.userSelected?.profiles || {};
2304
2478
  // 10.19.9 Phase 3: capture a snapshot of userSelected.models BEFORE
2305
2479
  // applyModels overwrites the block, so the post-confirm status screen
2306
2480
  // can classify re-confirmed picks as `preexisting` (⤳) instead of
2307
2481
  // `fresh` (✔). `current` is read once and shared with pickModelsFn;
2308
2482
  // we wrap it in a Set so classifyPickStatus can use `.has(id)`.
2309
2483
  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
2484
  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
2485
  const fetchProviderCatalogFn = deps.fetchProviderCatalog
2321
2486
  || (deps.fetchModelsDevCatalog ? undefined : fetchProviderCatalog);
2487
+ // Fetch both catalogs once, concurrently, before rendering. Reuse this map
2488
+ // after confirmation so the picker and persisted settings see identical
2489
+ // metadata and confirmation never causes a second network round trip.
2322
2490
  const enrichment = await enrichPicksByMetadata({
2323
2491
  candidates,
2324
- pickedIds: picked,
2492
+ pickedIds: candidates.map((candidate) => candidate.id),
2325
2493
  fetchFn: fetchModelsDevCatalogFn,
2326
2494
  providerFetchFn: fetchProviderCatalogFn,
2327
2495
  });
2328
2496
  const profilesMap = enrichment.profiles;
2497
+ const enrichedCandidates = candidates.map((candidate) => {
2498
+ const fresh = profilesMap.get(candidate.id);
2499
+ const cached = previousProfiles[candidate.id];
2500
+ const profile = fresh?.metadata?.source === 'models.dev' ? fresh : (cached || fresh || null);
2501
+ return { ...candidate, profile, contextWindow: profile?.limits?.contextTokens ?? null };
2502
+ });
2503
+ const picked = await pickModelsFn({ candidates: enrichedCandidates, current });
2329
2504
  const modelsDevStatus = enrichment.modelsDev && Object.keys(enrichment.modelsDev).length > 0
2330
- ? { status: 'ok', source: MODELS_DEV_CATALOG_URL, matched: [...profilesMap.values()].filter((p) => p && p.metadata && p.metadata.source === 'models.dev').length, total: picked.length }
2505
+ ? { status: 'ok', source: MODELS_DEV_CATALOG_URL, matched: picked.filter((id) => profilesMap.get(id)?.metadata?.source === 'models.dev').length, total: picked.length }
2331
2506
  : { 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
2507
 
2333
2508
  if (picked.length === 0) {
2334
- const previousProfiles = loadRouter(routerPath)?.userSelected?.profiles || {};
2335
2509
  const block = applyModels({ routerPath, models: [], source: 'live-pick' });
2336
2510
  // Clear Claude Code modelOverrides + modelPicker when the picker is
2337
2511
  // emptied so the session no longer claims to recognise removed IDs.
@@ -2356,7 +2530,13 @@ export async function run(name, args, isHelpRequest, deps = {}) {
2356
2530
  const profiles = {};
2357
2531
  for (const id of picked) {
2358
2532
  tierHints[id] = defaultTierHint(id);
2359
- const profile = profilesMap.get(id);
2533
+ const fresh = profilesMap.get(id);
2534
+ // A transient catalog miss must not erase a previously good profile.
2535
+ // Gateway fallback data remains useful for new models, but cached
2536
+ // Models.dev/operator facts take precedence when already present.
2537
+ const profile = fresh?.metadata?.source === 'models.dev'
2538
+ ? fresh
2539
+ : (previousProfiles[id] || fresh);
2360
2540
  if (profile) profiles[id] = profile;
2361
2541
  }
2362
2542
  // F-191 / 10.19.2 — stale-ID detection: the picker shows candidates
@@ -2366,7 +2546,6 @@ export async function run(name, args, isHelpRequest, deps = {}) {
2366
2546
  // Code still surfaces the unrecognized_model diagnostic.
2367
2547
  const liveIds = candidates.map((c) => c.id);
2368
2548
  const partition = partitionStalePicks({ liveIds, pickedIds: picked });
2369
- const previousProfiles = loadRouter(routerPath)?.userSelected?.profiles || {};
2370
2549
  const block = applyModels({ routerPath, models: picked, tierHints, profiles, source: 'live-pick' });
2371
2550
  if (partition.staleIds.length > 0) {
2372
2551
  block.staleIds = partition.staleIds;
@@ -2388,7 +2567,7 @@ export async function run(name, args, isHelpRequest, deps = {}) {
2388
2567
  // equivalent data shape.
2389
2568
  const statusResult = renderPickStatusScreen({
2390
2569
  picked,
2391
- profiles: profilesMap,
2570
+ profiles,
2392
2571
  preExisting,
2393
2572
  out: { write: () => true },
2394
2573
  isTTY: false,
@@ -2426,7 +2605,7 @@ export async function run(name, args, isHelpRequest, deps = {}) {
2426
2605
  // operator feedback there.
2427
2606
  const statusResult = renderPickStatusScreen({
2428
2607
  picked,
2429
- profiles: profilesMap,
2608
+ profiles,
2430
2609
  preExisting,
2431
2610
  out: process.stdout,
2432
2611
  isTTY: !!process.stdout.isTTY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.23.6",
3
+ "version": "10.23.7",
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": {
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export declare const SDK_VERSION: "10.23.6";
4
+ export declare const SDK_VERSION: "10.23.7";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export const SDK_VERSION = "10.23.6";
4
+ export const SDK_VERSION = "10.23.7";
5
5
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.23.6",
3
+ "version": "10.23.7",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",