@polderlabs/bizar 10.19.6 → 10.19.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.
@@ -227,7 +227,15 @@ function normalizedModelIdentity(id) {
227
227
  };
228
228
  }
229
229
 
230
- function toCapabilityProfile(gatewayId, match, matchType, confidence) {
230
+ /**
231
+ * Build a per-model profile object from a Models.dev catalog row.
232
+ *
233
+ * Phase 1 (v10.19.7): in addition to the long-standing `name` / `family` /
234
+ * `capabilities` / `limits` / `metadata` fields, propagate Models.dev's
235
+ * `description` and `summary` onto the profile so Phase 3's status screen
236
+ * (10.19.9) can render the description without re-querying the catalog.
237
+ */
238
+ export function toCapabilityProfile(gatewayId, match, matchType, confidence) {
231
239
  const limit = match.limit && typeof match.limit === 'object' ? match.limit : {};
232
240
  const modalities = match.modalities && typeof match.modalities === 'object' ? match.modalities : {};
233
241
  return {
@@ -235,6 +243,8 @@ function toCapabilityProfile(gatewayId, match, matchType, confidence) {
235
243
  baseModel: match.id,
236
244
  name: typeof match.name === 'string' ? match.name : match.id,
237
245
  family: typeof match.family === 'string' ? match.family : null,
246
+ description: typeof match.description === 'string' ? match.description : null,
247
+ summary: typeof match.summary === 'string' ? match.summary : null,
238
248
  capabilities: {
239
249
  attachment: match.attachment === true,
240
250
  reasoning: match.reasoning === true,
@@ -265,6 +275,15 @@ function toCapabilityProfile(gatewayId, match, matchType, confidence) {
265
275
  * Enrich gateway-discovered models with Models.dev profiles. Exact IDs win.
266
276
  * A normalized/suffix match is accepted only when unique; ambiguous models
267
277
  * remain unmatched rather than receiving guessed capabilities.
278
+ *
279
+ * Phase 1 (v10.19.7): when Models.dev has no row for the candidate AND the
280
+ * candidate carries gateway-supplied `_gateway.name` / `_gateway.description`,
281
+ * build a minimal `profile` so the picker row renderer can read
282
+ * `profile.name` / `profile.description` directly without dereferencing
283
+ * `_gateway`. Candidates whose `normalizeModels` output had no `_gateway`
284
+ * fields keep the legacy `profile === null` contract so `capabilityLabel`
285
+ * still returns `'metadata unavailable'` (Phase 2 owns the rewrite that
286
+ * lets a non-null profile render the `'metadata unavailable'` label).
268
287
  */
269
288
  export function enrichModelsWithCapabilities(candidates, catalog) {
270
289
  const entries = flattenModelsDevCatalog(catalog);
@@ -286,8 +305,196 @@ export function enrichModelsWithCapabilities(candidates, catalog) {
286
305
  const profile = toCapabilityProfile(gatewayId, matches[0], 'unique-normalized-id', 0.7);
287
306
  return { ...candidate, profile, contextWindow: profile.limits.contextTokens };
288
307
  }
289
- return { ...candidate, profile: null, contextWindow: null };
308
+ // Models.dev miss: if the candidate carries gateway-supplied label /
309
+ // description, build a minimal `profile` so the picker row renderer
310
+ // can read `profile.name` / `profile.description` directly. Candidates
311
+ // that arrived from `normalizeModels` WITHOUT any `_gateway` data keep
312
+ // the legacy `profile === null` contract so `capabilityLabel(null)`
313
+ // still returns `'metadata unavailable'` (Phase 2 owns that rewrite).
314
+ const gw = (candidate && typeof candidate._gateway === 'object' && candidate._gateway) || {};
315
+ const gatewayName = typeof gw.name === 'string' ? gw.name
316
+ : (typeof gw.display_name === 'string' ? gw.display_name : null);
317
+ const gatewayDescription = typeof gw.description === 'string' ? gw.description : null;
318
+ if (gatewayName === null && gatewayDescription === null) {
319
+ return { ...candidate, profile: null, contextWindow: null };
320
+ }
321
+ const fallbackProfile = {
322
+ gatewayId,
323
+ baseModel: gatewayId,
324
+ name: gatewayName,
325
+ family: null,
326
+ description: gatewayDescription,
327
+ summary: null,
328
+ capabilities: {
329
+ attachment: false,
330
+ reasoning: false,
331
+ toolCall: false,
332
+ structuredOutput: false,
333
+ temperature: true,
334
+ inputModalities: ['text'],
335
+ outputModalities: ['text'],
336
+ },
337
+ limits: { contextTokens: null, inputTokens: null, outputTokens: null },
338
+ releaseDate: null,
339
+ lastUpdated: null,
340
+ metadata: {
341
+ source: 'gateway-fallback',
342
+ sourceUrl: null,
343
+ retrievedAt: new Date().toISOString(),
344
+ matchType: 'gateway-fallback',
345
+ confidence: 0,
346
+ },
347
+ };
348
+ return { ...candidate, profile: fallbackProfile, contextWindow: null };
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Strip a `<provider>/` prefix from a model id. Used as a last-ditch catalog
354
+ * lookup key when the gateway reports the model under a wrapper namespace
355
+ * (e.g. `claude-minimax/MiniMax-M3`) and the models.dev catalog uses the bare
356
+ * form (`minimax/MiniMax-M3`).
357
+ *
358
+ * @param {string} id
359
+ * @returns {string}
360
+ */
361
+ function stripProvider(id) {
362
+ const raw = String(id || '').trim();
363
+ const slash = raw.indexOf('/');
364
+ return slash >= 0 ? raw.slice(slash + 1) : raw;
365
+ }
366
+
367
+ /**
368
+ * Phase 2 lazy enrichment. The interactive picker path used to call
369
+ * `fetchModelsDevCatalog` BEFORE the picker opened, paying the round-trip
370
+ * on every `--list`/`--set` (which never even consulted the catalog) and
371
+ * on every interactive session that the user then aborted.
372
+ *
373
+ * After Phase 2 the catalog fetch moves to AFTER picker confirmation:
374
+ * - `--list` and `--set` skip the fetch entirely.
375
+ * - The interactive path only fetches when the user actually picked
376
+ * one or more models, and only the picked IDs are enriched.
377
+ *
378
+ * Concurrency: per-id lookups run in parallel with a bounded worker pool
379
+ * (`concurrency`, default 8) and a per-id timeout (`timeoutMs`, default
380
+ * 3000ms). On per-id timeout or lookup failure, we fall back to the
381
+ * candidate's Phase 1 `_gateway.name` contract (when present); when no
382
+ * `_gateway` block is attached, the profile is `null`.
383
+ *
384
+ * @param {{
385
+ * candidates: Array<{ id: string, owned_by?: string|null, _gateway?: { name?: string|null, display_name?: string|null, description?: string|null } }>,
386
+ * pickedIds: string[],
387
+ * fetchFn?: typeof fetchModelsDevCatalog,
388
+ * timeoutMs?: number,
389
+ * concurrency?: number,
390
+ * now?: () => number,
391
+ * }} opts
392
+ * @returns {Promise<{ profiles: Map<string, object|null>, modelsDev: object }>}
393
+ */
394
+ export async function enrichPicksByMetadata({
395
+ candidates,
396
+ pickedIds,
397
+ fetchFn = fetchModelsDevCatalog,
398
+ timeoutMs = 3000,
399
+ concurrency = 8,
400
+ } = {}) {
401
+ // Wholesale catalog fetch — best-effort. A network failure on the
402
+ // initial fetch degrades to an empty map; per-id timeouts on the
403
+ // downstream enrichment path fall back to `_gateway.name`.
404
+ // The wholesale fetch inherits `timeoutMs` so a hung stub does not
405
+ // block the picker-confirmation step indefinitely.
406
+ const catalog = await Promise.race([
407
+ fetchFn({}),
408
+ new Promise((_, reject) => setTimeout(() => reject(new Error('enrichPicksByMetadata: wholesale fetch timeout')), timeoutMs)),
409
+ ]).catch(() => ({}));
410
+ const catalogMap = flattenModelsDevCatalog(catalog);
411
+
412
+ const out = new Map();
413
+ const ids = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id) : [];
414
+ // Stable order so the work array indexes are predictable for the
415
+ // bounded runner below.
416
+ const work = ids.map((id, index) => ({ id, index }));
417
+ if (work.length === 0) return { profiles: out, modelsDev: catalog };
418
+
419
+ const findCandidate = (id) => (Array.isArray(candidates) ? candidates.find((c) => c && c.id === id) : null);
420
+
421
+ // Bounded-concurrency worker pool. Each worker pulls jobs off the
422
+ // queue until empty. Per-id failure (timeout or thrown) falls back to
423
+ // the candidate's `_gateway` block when present, otherwise leaves
424
+ // the profile as `null`.
425
+ const queue = [...work];
426
+ const workerCount = Math.max(1, Math.min(concurrency, queue.length));
427
+ const workers = Array.from({ length: workerCount }, async () => {
428
+ while (queue.length > 0) {
429
+ const job = queue.shift();
430
+ if (!job) return;
431
+ const { id } = job;
432
+ const candidate = findCandidate(id);
433
+ if (!candidate) {
434
+ out.set(id, null);
435
+ continue;
436
+ }
437
+ try {
438
+ await Promise.race([
439
+ // Each "job" resolves immediately with the profile lookup; the
440
+ // race is a defense-in-depth measure so a misbehaving fetchFn
441
+ // cannot stall the worker past `timeoutMs`.
442
+ Promise.resolve().then(() => {
443
+ const lower = String(id).toLowerCase();
444
+ const match = catalogMap.get(lower)
445
+ || catalogMap.get(stripProvider(id).toLowerCase());
446
+ let profile;
447
+ if (match) {
448
+ // Re-use the existing capability-profile shape; the new
449
+ // helper differs from `enrichModelsWithCapabilities` only
450
+ // in WHEN the catalog is fetched and WHICH ids are
451
+ // enriched (confirmed picks only).
452
+ profile = toCapabilityProfile(id, match, 'exact-id', 0.9);
453
+ } else if (candidate._gateway) {
454
+ profile = {
455
+ name: candidate._gateway.name ?? candidate._gateway.display_name ?? null,
456
+ description: candidate._gateway.description ?? null,
457
+ summary: null,
458
+ ownedBy: candidate.owned_by ?? null,
459
+ supportsTools: null,
460
+ supportsVision: null,
461
+ contextWindow: null,
462
+ costTier: null,
463
+ confidence: 0,
464
+ metadata: { source: 'gateway-fallback' },
465
+ };
466
+ } else {
467
+ profile = null;
468
+ }
469
+ out.set(id, profile);
470
+ }),
471
+ new Promise((_, reject) => setTimeout(() => reject(new Error('enrichPicksByMetadata: per-id timeout')), timeoutMs)),
472
+ ]);
473
+ } catch {
474
+ // Per-id failure (timeout or thrown). Fall back to `_gateway.name`
475
+ // when present so the Phase 1 contract still surfaces a name;
476
+ // otherwise the profile is null.
477
+ if (candidate._gateway) {
478
+ out.set(id, {
479
+ name: candidate._gateway.name ?? candidate._gateway.display_name ?? null,
480
+ description: candidate._gateway.description ?? null,
481
+ summary: null,
482
+ ownedBy: candidate.owned_by ?? null,
483
+ supportsTools: null,
484
+ supportsVision: null,
485
+ contextWindow: null,
486
+ costTier: null,
487
+ confidence: 0,
488
+ metadata: { source: 'gateway-fallback' },
489
+ });
490
+ } else {
491
+ out.set(id, null);
492
+ }
493
+ }
494
+ }
290
495
  });
496
+ await Promise.all(workers);
497
+ return { profiles: out, modelsDev: catalog };
291
498
  }
292
499
 
293
500
  function capabilityLabel(profile) {
@@ -379,7 +586,24 @@ async function fetchOnce({ doFetch, url, authToken, timeoutMs }) {
379
586
  }
380
587
  }
381
588
 
382
- function normalizeModels(body) {
589
+ /**
590
+ * Normalize the gateway `/models` response into the candidate-pool shape
591
+ * consumed by the picker.
592
+ *
593
+ * Phase 1 (v10.19.7): in addition to the existing `id` / `owned_by` / `kind`
594
+ * fields, preserve the gateway's optional `name` / `display_name` /
595
+ * `description` payload under a new `_gateway` sub-object. The picker row
596
+ * renderer reads `profile.name` / `profile.description`, so when the
597
+ * Models.dev enrichment misses (Phase 2) the renderer can still surface a
598
+ * gateway-supplied label or description rather than an id-derived fallback.
599
+ *
600
+ * NOTE: `_gateway` is in-memory only. `applyModels` never writes it to
601
+ * `model-router.json`; the persisted shape stays as it was before this
602
+ * change. See `models-namespace-sync.test.mjs#normalizeModels does not
603
+ * persist _gateway into userSelected on round-trip` for the regression
604
+ * pin.
605
+ */
606
+ export function normalizeModels(body) {
383
607
  if (!body || typeof body !== 'object') return [];
384
608
  const list = Array.isArray(body.data) ? body.data : Array.isArray(body) ? body : [];
385
609
  const out = [];
@@ -388,7 +612,11 @@ function normalizeModels(body) {
388
612
  const id = typeof m.id === 'string' ? m.id.trim() : '';
389
613
  if (!id) continue;
390
614
  const owned = typeof m.owned_by === 'string' ? m.owned_by : '';
391
- out.push({ id, owned_by: owned, kind: classifyKind(id) });
615
+ const gw = {};
616
+ if (typeof m.name === 'string' && m.name) gw.name = m.name;
617
+ if (typeof m.display_name === 'string' && m.display_name) gw.display_name = m.display_name;
618
+ if (typeof m.description === 'string' && m.description) gw.description = m.description;
619
+ out.push({ id, owned_by: owned, kind: classifyKind(id), _gateway: gw });
392
620
  }
393
621
  // Stable order — by id — so the picker does not shuffle between runs.
394
622
  out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
@@ -1374,7 +1602,16 @@ function parseSet(value) {
1374
1602
  .filter((s) => s.length > 0);
1375
1603
  }
1376
1604
 
1377
- export async function run(name, args, isHelpRequest) {
1605
+ export async function run(name, args, isHelpRequest, deps = {}) {
1606
+ // `bizar model` remains a deprecated alias; `bizar models` is the canonical surface.
1607
+ // `deps` is an optional test-injection surface (10.19.8 Phase 2):
1608
+ // - `pickModels` — override the interactive picker (for tests that
1609
+ // auto-confirm without a TTY)
1610
+ // - `fetchModelsDevCatalog` — override the catalog fetch (for tests
1611
+ // that count calls or stub models.dev)
1612
+ // - `listModels` — override the gateway `/models` fetch (for tests)
1613
+ // All three default to the module-level exports; production callers
1614
+ // see no behaviour change.
1378
1615
  // `bizar model` remains a deprecated alias; `bizar models` is the canonical surface.
1379
1616
  if (name !== 'models' && name !== 'model') return false;
1380
1617
  if (isHelpRequest || args.includes('--help') || args.includes('-h')) {
@@ -1537,32 +1774,21 @@ export async function run(name, args, isHelpRequest) {
1537
1774
  // We do not short-circuit here — the code below falls into the
1538
1775
  // `wantList || isDeprecatedAlias` branch and prints candidate IDs.
1539
1776
 
1777
+ // Phase 2 (10.19.8): the Models.dev catalog fetch used to happen HERE,
1778
+ // before any flag was inspected. That paid the round-trip on every
1779
+ // `--list`, every `--set`, every aborted picker, and every CI run that
1780
+ // never looked at the result. Move the fetch to AFTER picker
1781
+ // confirmation (see the interactive branch below); `--list` and
1782
+ // `bizar model` now skip the fetch entirely.
1540
1783
  let candidates;
1541
- let modelsDev = { status: 'not-attempted', matched: 0, total: 0 };
1784
+ let modelsDev = { status: 'skipped', matched: 0, total: 0, source: MODELS_DEV_CATALOG_URL, note: 'lazy fetch — --list does not contact models.dev' };
1542
1785
  try {
1543
1786
  // The deprecated `bizar model` alias preserves the legacy
1544
1787
  // "retry-without-auth on 401" behavior so existing scripts keep working.
1545
1788
  // The new `bizar models` surface fails fast on 401 with an actionable
1546
1789
  // error message (the user explicitly picks models — no silent retry).
1547
- candidates = await listModels({ endpoint, authToken, retryWithoutAuth: isDeprecatedAlias });
1548
- try {
1549
- const catalogue = await fetchModelsDevCatalog({});
1550
- candidates = enrichModelsWithCapabilities(candidates, catalogue);
1551
- modelsDev = {
1552
- status: 'ok',
1553
- matched: candidates.filter((candidate) => candidate.profile).length,
1554
- total: candidates.length,
1555
- source: MODELS_DEV_CATALOG_URL,
1556
- };
1557
- } catch (metadataError) {
1558
- modelsDev = {
1559
- status: 'unavailable',
1560
- matched: 0,
1561
- total: candidates.length,
1562
- source: MODELS_DEV_CATALOG_URL,
1563
- error: metadataError instanceof Error ? metadataError.message : String(metadataError),
1564
- };
1565
- }
1790
+ const listModelsFn = deps.listModels || listModels;
1791
+ candidates = await listModelsFn({ endpoint, authToken, retryWithoutAuth: isDeprecatedAlias });
1566
1792
  } catch (err) {
1567
1793
  if (err.name === 'AbortError' || String(err.message).includes('aborted')) {
1568
1794
  console.error(chalk.red(` x Request timed out after 3000ms - ${endpoint}/models`));
@@ -1593,7 +1819,10 @@ export async function run(name, args, isHelpRequest) {
1593
1819
  // Interactive picker — only reached when explicitly invoked as `bizar models`
1594
1820
  // with NO --list / --clear / --set flags. We avoid running the picker when
1595
1821
  // stdin is not a TTY (e.g. CI / npm test), because readline.question would block.
1596
- if (!process.stdin.isTTY) {
1822
+ // 10.19.8 Phase 2: when `deps.pickModels` is provided (test injection),
1823
+ // skip the TTY guard — the test harness is responsible for the picker.
1824
+ const pickModelsFn = deps.pickModels || pickModels;
1825
+ if (!process.stdin.isTTY && !deps.pickModels) {
1597
1826
  if (wantJson) {
1598
1827
  process.stdout.write(JSON.stringify({
1599
1828
  endpoint,
@@ -1612,7 +1841,24 @@ export async function run(name, args, isHelpRequest) {
1612
1841
 
1613
1842
  const router = loadRouter(routerPath);
1614
1843
  const { models: current } = currentSelection(router);
1615
- const picked = await pickModels({ candidates, current });
1844
+ const picked = await pickModelsFn({ candidates, current });
1845
+
1846
+ // Phase 2 (10.19.8): the Models.dev catalog fetch moves HERE — only
1847
+ // AFTER the operator has actually confirmed picks. Per-id enrichment
1848
+ // runs in parallel with bounded concurrency (8 workers) and a per-id
1849
+ // timeout (3000ms); per-id failures fall back to the Phase 1
1850
+ // `_gateway.name` contract.
1851
+ const fetchModelsDevCatalogFn = deps.fetchModelsDevCatalog || fetchModelsDevCatalog;
1852
+ const enrichment = await enrichPicksByMetadata({
1853
+ candidates,
1854
+ pickedIds: picked,
1855
+ fetchFn: fetchModelsDevCatalogFn,
1856
+ });
1857
+ const profilesMap = enrichment.profiles;
1858
+ const modelsDevStatus = enrichment.modelsDev && Object.keys(enrichment.modelsDev).length > 0
1859
+ ? { status: 'ok', source: MODELS_DEV_CATALOG_URL, matched: [...profilesMap.values()].filter((p) => p && p.metadata && p.metadata.source === 'models.dev').length, total: picked.length }
1860
+ : { status: 'unavailable', source: MODELS_DEV_CATALOG_URL, matched: 0, total: picked.length, note: 'wholesale fetch failed; per-id enrichment degraded to _gateway.name fallback' };
1861
+
1616
1862
  if (picked.length === 0) {
1617
1863
  const block = applyModels({ routerPath, models: [], source: 'live-pick' });
1618
1864
  // Clear Claude Code modelOverrides + modelPicker when the picker is
@@ -1620,7 +1866,14 @@ export async function run(name, args, isHelpRequest) {
1620
1866
  applyModelOverrides({ pickedIds: [], liveIds: [] });
1621
1867
  applyModelPicker({ pickedIds: [], liveIds: [] });
1622
1868
  if (wantJson) {
1623
- process.stdout.write(JSON.stringify({ applied: block, endpoint, endpointSource }, null, 2) + '\n');
1869
+ process.stdout.write(JSON.stringify({
1870
+ applied: block,
1871
+ endpoint,
1872
+ endpointSource,
1873
+ enriched: [],
1874
+ profiles: {},
1875
+ modelsDev: modelsDevStatus,
1876
+ }, null, 2) + '\n');
1624
1877
  } else {
1625
1878
  console.log(chalk.yellow(' ! No models selected - userSelected is now empty; orchestrator will fall back to session-only.'));
1626
1879
  }
@@ -1630,7 +1883,7 @@ export async function run(name, args, isHelpRequest) {
1630
1883
  const profiles = {};
1631
1884
  for (const id of picked) {
1632
1885
  tierHints[id] = defaultTierHint(id);
1633
- const profile = candidates.find((candidate) => candidate.id === id)?.profile;
1886
+ const profile = profilesMap.get(id);
1634
1887
  if (profile) profiles[id] = profile;
1635
1888
  }
1636
1889
  // F-191 / 10.19.2 — stale-ID detection: the picker shows candidates
@@ -1649,7 +1902,21 @@ export async function run(name, args, isHelpRequest) {
1649
1902
  // picks drive the picker without relying on gateway discovery.
1650
1903
  const picker = applyModelPicker({ pickedIds: picked, profiles, liveIds });
1651
1904
  if (wantJson) {
1652
- process.stdout.write(JSON.stringify({ applied: block, endpoint, endpointSource, sync, picker }, null, 2) + '\n');
1905
+ // Phase 2 (10.19.8): the interactive JSON output gains an `enriched`
1906
+ // key naming the picked IDs that received Models.dev enrichment. For
1907
+ // Phase 2 the array equals the picks list (no filtering); Phase 4
1908
+ // will filter to only the IDs that actually received a profile.
1909
+ const enriched = picked.slice();
1910
+ process.stdout.write(JSON.stringify({
1911
+ applied: block,
1912
+ endpoint,
1913
+ endpointSource,
1914
+ enriched,
1915
+ profiles,
1916
+ modelsDev: modelsDevStatus,
1917
+ sync,
1918
+ picker,
1919
+ }, null, 2) + '\n');
1653
1920
  } else {
1654
1921
  console.log(chalk.green(`\n v Saved ${block.models.length} model(s) to ${routerPath}:`));
1655
1922
  console.log(chalk.dim(` Models.dev profiles: ${Object.keys(block.profiles || {}).length}/${block.models.length}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.19.6",
3
+ "version": "10.19.8",
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.19.6";
4
+ export declare const SDK_VERSION: "10.19.8";
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.19.6";
4
+ export const SDK_VERSION = "10.19.8";
5
5
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.19.6",
3
+ "version": "10.19.8",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",