@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.
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.
|
|
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
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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) =>
|
|
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
|
|
@@ -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 /
|
|
1494
|
-
* esc / ?).
|
|
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
|
|
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
|
-
|
|
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 >
|
|
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 =
|
|
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 >
|
|
1579
|
-
const id =
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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,
|
|
1699
|
-
scrollTop = Math.max(0,
|
|
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(
|
|
1870
|
+
const end = Math.min(visibleCandidates.length, start + VIEWPORT_SIZE);
|
|
1710
1871
|
const above = start;
|
|
1711
|
-
const below =
|
|
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
|
|
1875
|
+
const candidate = visibleCandidates[i];
|
|
1876
|
+
const originalIndex = originalIndices.get(candidate) ?? i;
|
|
1715
1877
|
const row = fitRow({
|
|
1716
|
-
i,
|
|
1717
|
-
id:
|
|
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(
|
|
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 ·
|
|
1728
|
-
if (showHelp) lines.push(`\x1b[K${chalk.dim(` Extra:
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
2608
|
+
profiles,
|
|
2430
2609
|
preExisting,
|
|
2431
2610
|
out: process.stdout,
|
|
2432
2611
|
isTTY: !!process.stdout.isTTY,
|
package/package.json
CHANGED