amicus 4.9.8 → 4.10.0

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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @module sidecar/aliases
3
+ * `amicus aliases` (#238 D4) — the user's alias map as a standing command.
4
+ *
5
+ * amicus aliases list: following / pinned, grouped by vendor
6
+ * amicus aliases --review picker over every proposal (aliases-review.js)
7
+ * amicus aliases --json versioned document: rows + proposals
8
+ *
9
+ * The LIST reads the catalog CACHE at any age and never networks (§5 display
10
+ * gate); the picker refreshes inline when the cache is stale (write gate).
11
+ * Every form normalizes the config on entry (D6), best-effort.
12
+ *
13
+ * #249 r2 C4: `renderAliasList`'s alias names, ids and vendor-group labels
14
+ * (an unmapped vendor's label is `titleCaseVendor` of a config VALUE's
15
+ * segment — still third-party, per review F1), and the typed name in
16
+ * `handleUnpin`'s messages, are quoted onto a terminal and ride `safeFragment`
17
+ * (the house sanitizer, `utils/text-sanitize.js`) — the fragment, never the
18
+ * composed line, per `alias-shadow.js :: formatAliasShadow`'s rule. A caught
19
+ * `err.message` is a sentence, not an id, so it rides `collapseExcerpt` at
20
+ * the house default cap instead (the `describeThrown` precedent).
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const { SCHEMA_VERSION } = require('../utils/result-schema-version');
26
+ const { DEFAULT_MAX_AGE_MS } = require('../utils/model-catalog');
27
+ const { stripGatewayPrefix } = require('../utils/curated-models');
28
+ const { safeFragment, collapseExcerpt } = require('../utils/text-sanitize');
29
+
30
+ /** @returns {object} this module's collaborators, gathered so a caller can override them in tests */
31
+ function loadDeps() {
32
+ const config = require('../utils/config');
33
+ return {
34
+ config,
35
+ normalizeAliases: require('../utils/alias-state').normalizeAliases,
36
+ listAliasRows: require('../utils/alias-state').listAliasRows,
37
+ buildAliasProposals: require('../utils/alias-proposals').buildAliasProposals,
38
+ readDismissals: require('../utils/alias-store').readDismissals,
39
+ getCatalogInfo: require('../utils/model-catalog').getCatalogInfo,
40
+ readCache: require('../utils/model-catalog').readCache,
41
+ groupAliases: require('../utils/alias-groups').groupAliases,
42
+ };
43
+ }
44
+
45
+ /** @returns {boolean} true when an own value of `aliases` is not a non-empty string -- saveConfig's own stripper would remove it */
46
+ function hasStrippableAliasValue(aliases) {
47
+ return Object.keys(aliases).some(k => typeof aliases[k] !== 'string' || aliases[k].length === 0);
48
+ }
49
+
50
+ /**
51
+ * Normalize on entry (D6), best-effort: a read-only config dir never blocks a
52
+ * listing — the in-memory normalized view is used and the failure announced.
53
+ * Also fires on a non-string value (#249 r1 R8a): `normalizeAliases` only
54
+ * drops a value equal to the shipped default, so a garbage value would
55
+ * otherwise sit on disk forever -- `saveConfig`'s own stripper removes it,
56
+ * with its own Notice.
57
+ * @returns {object} the user alias map after normalization
58
+ */
59
+ function normalizeOnEntry(d) {
60
+ const cfg = d.config.loadConfig();
61
+ const defaults = d.config.getDefaultAliases();
62
+ if (!cfg || !cfg.aliases || typeof cfg.aliases !== 'object') { return {}; }
63
+ const probe = d.normalizeAliases(cfg.aliases, defaults);
64
+ if (probe.removed.length > 0 || hasStrippableAliasValue(cfg.aliases)) {
65
+ try { d.config.saveConfig(cfg); } // saveConfig prints the Notices
66
+ catch (err) { process.stderr.write(`Notice: could not normalize aliases (${collapseExcerpt(err.message)}) — keys left on disk; every alias still resolves to the same id\n`); }
67
+ }
68
+ return probe.aliases;
69
+ }
70
+
71
+ /**
72
+ * @param {{maxAgeMs?: number}} [opts] `Number.POSITIVE_INFINITY` = cache only
73
+ * @returns {Promise<{rows: Array, proposals: Array, catalogInfo: object, catalogAvailable: boolean}>}
74
+ */
75
+ async function collectAliasView(opts = {}, d = loadDeps()) {
76
+ const userAliases = normalizeOnEntry(d);
77
+ const defaults = d.config.getDefaultAliases();
78
+ let catalogInfo = { models: [], fetchedAt: null, providerFailures: [] };
79
+ try {
80
+ if (opts.maxAgeMs === Number.POSITIVE_INFINITY) {
81
+ // Display gate (§5): the LIST never networks, even to fill an empty or
82
+ // v1 cache — read whatever is on disk, verbatim, with no freshness
83
+ // check at all. `getCatalogInfo` cannot be reused here: it always
84
+ // calls `getCatalog`, which refreshes (a real fetch) the moment
85
+ // `readCache()` returns null, regardless of `maxAgeMs`.
86
+ const c = d.readCache();
87
+ catalogInfo = {
88
+ models: (c && Array.isArray(c.models)) ? c.models : [],
89
+ fetchedAt: c && typeof c.fetchedAt === 'number' ? c.fetchedAt : null,
90
+ providerFailures: (c && Array.isArray(c.providerFailures)) ? c.providerFailures : [],
91
+ };
92
+ } else {
93
+ // The picker's default-age path (Task 8): a stale cache refreshes inline.
94
+ catalogInfo = await d.getCatalogInfo(opts.maxAgeMs === undefined ? {} : { maxAgeMs: opts.maxAgeMs });
95
+ }
96
+ } catch (err) { process.stderr.write(`Notice: catalog unavailable (${collapseExcerpt(err.message)}) — no proposals\n`); }
97
+ const rows = d.listAliasRows(userAliases, defaults);
98
+ const proposals = d.buildAliasProposals({ userAliases, defaults, catalogInfo, dismissed: d.readDismissals() });
99
+ return { rows, proposals, catalogInfo, catalogAvailable: (catalogInfo.models || []).length > 0 };
100
+ }
101
+
102
+ /**
103
+ * F2: the per-row flag names the SPECIFIC reason a proposal exists, instead
104
+ * of a blanket "newer available" that was wrong for two of the three kinds —
105
+ * an ahead-of-shipped pin with no sibling only differs from the shipped pin,
106
+ * and a stale pin with no catalog match at all is gone from the catalog
107
+ * outright, neither of which is "newer available".
108
+ * @param {string[]} reasons a proposal's `reasons` array
109
+ * @returns {string} the row suffix, or '' when called with no reasons
110
+ */
111
+ function rowFlag(reasons) {
112
+ if (!reasons || reasons.length === 0) { return ''; }
113
+ if (reasons.includes('newer-sibling')) { return ' ⚠ newer available'; }
114
+ if (reasons.includes('stale')) { return ' ⚠ gone from catalog'; }
115
+ return ' differs from shipped';
116
+ }
117
+
118
+ /**
119
+ * R4 (#249 r1 C1): a pinned curated row that names the shipped model under a
120
+ * DIFFERENT gateway form gets no proposal at all -- alias-proposals.js's own
121
+ * `sameModel` already treats it as identical to the shipped pin, so it is
122
+ * neither stale nor "differing". Without this note it renders identically
123
+ * to an arbitrary custom pin, losing the fact that it is the shipped
124
+ * recommendation in a different form. Truthful transparency, not a warning.
125
+ * @param {{state:string, curated:boolean, id:string, shipped:string|null}} r
126
+ * @returns {string} the row suffix, or '' when the note does not apply
127
+ */
128
+ function sameGatewayNote(r) {
129
+ if (r.state !== 'pinned' || !r.curated || typeof r.id !== 'string' || typeof r.shipped !== 'string') { return ''; }
130
+ if (r.id === r.shipped) { return ''; }
131
+ return stripGatewayPrefix(r.id) === stripGatewayPrefix(r.shipped) ? ' same model as shipped, other gateway' : '';
132
+ }
133
+
134
+ /** @param {{rows: Array, proposals: Array}} view @param {Function} [groupAliases] injectable for tests; defaults to loadDeps().groupAliases so the published `(view) => string` signature works standalone @returns {string} */
135
+ function renderAliasList(view, groupAliases = loadDeps().groupAliases) {
136
+ const byAlias = new Map(view.rows.map(r => [r.alias, r]));
137
+ const proposalByAlias = new Map(view.proposals.map(p => [p.alias, p]));
138
+ const map = { __proto__: null };
139
+ for (const r of view.rows) { map[r.alias] = r.id; }
140
+ // #249 r2 C4: width is computed from the SANITIZED name -- a bidi/ANSI
141
+ // fragment stripped at render time must not skew the column alignment of
142
+ // every other row's padding.
143
+ const width = Math.max(6, ...view.rows.map(r => safeFragment(r.alias).length));
144
+ const lines = [];
145
+ for (const g of groupAliases(map)) {
146
+ // F1 (#249 r2 review, C4 residual): for a vendor NOT in ALIAS_VENDOR_LABELS,
147
+ // `alias-groups.js :: vendorLabel` title-cases the raw vendor segment of a
148
+ // config VALUE (`aliasVendorOf`) rather than mapping it to house text --
149
+ // that is still third-party data, unlike the ~30 mapped labels. Sanitized
150
+ // HERE, not inside `vendorLabel`: that helper also renders into the
151
+ // Electron setup UI's HTML context (`electron/setup-ui-alias-groups.js`),
152
+ // out of scope for this terminal-only house rule.
153
+ lines.push(` ${safeFragment(g.label)}`);
154
+ for (const key of g.keys) {
155
+ const r = byAlias.get(key);
156
+ const p = proposalByAlias.get(key);
157
+ const flag = p ? rowFlag(p.reasons) : sameGatewayNote(r);
158
+ lines.push(` ${safeFragment(key).padEnd(width)} → ${safeFragment(r.id).padEnd(44)} ${r.state}${flag}`);
159
+ }
160
+ }
161
+ lines.push('');
162
+ // F1: an unavailable catalog cannot be checked for updates at all -- that is
163
+ // a different fact from "checked, nothing found" and must not print the
164
+ // reassuring "nothing to review" line.
165
+ if (!view.catalogAvailable) {
166
+ lines.push(' catalog unavailable — cannot check for updates (amicus models --refresh)');
167
+ return lines.join('\n') + '\n';
168
+ }
169
+ const n = view.proposals.length;
170
+ lines.push(n === 0
171
+ ? ' nothing to review — amicus aliases --review'
172
+ : ` ${n} to review — amicus aliases --review`);
173
+ // F1: the catalog is available but stale -- name its age so a user who
174
+ // never runs --review still learns the background refresh isn't keeping up.
175
+ const fetchedAt = view.catalogInfo && view.catalogInfo.fetchedAt;
176
+ if (typeof fetchedAt === 'number' && (Date.now() - fetchedAt) > DEFAULT_MAX_AGE_MS) {
177
+ const days = Math.floor((Date.now() - fetchedAt) / DEFAULT_MAX_AGE_MS);
178
+ lines.push(` (catalog is ${days} day${days === 1 ? '' : 's'} old — amicus models --refresh)`);
179
+ }
180
+ return lines.join('\n') + '\n';
181
+ }
182
+
183
+ /** @returns {object} the `--json` document (fields only ever ADDED within SCHEMA_VERSION) */
184
+ function buildAliasesDoc(view) {
185
+ return {
186
+ schemaVersion: SCHEMA_VERSION,
187
+ type: 'aliases',
188
+ catalogAvailable: view.catalogAvailable,
189
+ catalogFetchedAt: view.catalogInfo.fetchedAt || null,
190
+ aliasCount: view.rows.length,
191
+ aliases: view.rows,
192
+ proposalCount: view.proposals.length,
193
+ proposals: view.proposals,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * `amicus aliases --unpin <name>` (#238 F6, R1): "unpin" and "delete" are one
199
+ * operation -- remove the key -- whose meaning is decided by whether the
200
+ * name is curated (D1). The name is trimmed before every use -- for the
201
+ * `removeAlias` lookup, the `isCurated` check and both success messages --
202
+ * so a padded name neither crashes nor mis-reports which branch fired
203
+ * (#249 r1 R1). Blank/whitespace/literal-'null' names are refused here, and
204
+ * `removeAlias` is called under try/catch so any other throw (its own name
205
+ * guard included) becomes a clean exit 1, never an uncaught crash.
206
+ *
207
+ * R4 (#249 r2 D2): refused BEFORE any write when `name` is also
208
+ * `config.default` and NOT curated -- deleting it would leave the default
209
+ * dangling on a key that no longer resolves (`resolveModel` throws), and
210
+ * silently doing that fails the product principle (never a silent dangling
211
+ * default) as hard as a crash. Precedent: `cli-handlers-provider.js ::
212
+ * doRemove` re-points `config.default` when a provider goes away; here the
213
+ * user is deleting one alias on purpose, so refusing and naming the fix is
214
+ * the transparent choice instead. A CURATED default is unaffected -- it
215
+ * keeps resolving from the shipped table after the unpin, same as any other
216
+ * curated unpin. `config.default` may also be a bare model id rather than
217
+ * an alias name (`start-helpers.js` resolves either); the guard compares
218
+ * against the literal `name` argument, so a default that merely happens to
219
+ * RESOLVE to the same id as this alias is not what it's checking.
220
+ * @param {*} rawName whatever `args.unpin` parsed to
221
+ * @returns {number} exit code
222
+ */
223
+ function handleUnpin(rawName) {
224
+ const name = typeof rawName === 'string' ? rawName.trim() : '';
225
+ if (!name || name === 'null') {
226
+ process.stderr.write('Error: --unpin requires an alias name\n');
227
+ return 1;
228
+ }
229
+ const { removeAlias } = require('../utils/alias-store');
230
+ const { isCurated } = require('../utils/alias-state');
231
+ const config = require('../utils/config');
232
+ const defaults = config.getDefaultAliases();
233
+ const cfg = config.loadConfig();
234
+ if (cfg && cfg.default === name && !isCurated(name, defaults)) {
235
+ process.stderr.write(`Error: '${safeFragment(name)}' is your default model (config.default) — pick another default first (amicus setup)\n`);
236
+ return 1;
237
+ }
238
+ let removed;
239
+ try {
240
+ removed = removeAlias(name);
241
+ } catch (err) {
242
+ process.stderr.write(`Error: ${collapseExcerpt(err.message)}\n`);
243
+ return 1;
244
+ }
245
+ if (!removed) {
246
+ process.stderr.write(`Error: '${safeFragment(name)}' is not pinned (see: amicus aliases)\n`);
247
+ return 1;
248
+ }
249
+ process.stdout.write(isCurated(name, defaults)
250
+ ? `✓ ${safeFragment(name)} now follows the shipped recommendation (${defaults[name]})\n`
251
+ : `✓ ${safeFragment(name)} removed\n`);
252
+ return 0;
253
+ }
254
+
255
+ /** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
256
+ async function handleAliases(args) {
257
+ if (args.review && (args.json || args.quiet)) {
258
+ process.stderr.write('Error: --review is interactive; use `amicus aliases --json` for machine output\n');
259
+ return 1;
260
+ }
261
+ if (args.unpin !== undefined) {
262
+ if (args.review || args.json) {
263
+ process.stderr.write('Error: --unpin cannot be combined with --review or --json\n');
264
+ return 1;
265
+ }
266
+ return handleUnpin(args.unpin); // handleUnpin trims/validates (R1): true, 42, '', ' ', 'null' all land the same error
267
+ }
268
+ if (args.review) { return require('./aliases-review').runReview(args); }
269
+ const d = loadDeps();
270
+ const view = await collectAliasView({ maxAgeMs: Number.POSITIVE_INFINITY }, d);
271
+ if (args.json) {
272
+ process.stdout.write(JSON.stringify(buildAliasesDoc(view), null, 2) + '\n');
273
+ return 0;
274
+ }
275
+ process.stdout.write(renderAliasList(view, d.groupAliases));
276
+ return 0;
277
+ }
278
+
279
+ module.exports = { handleAliases, collectAliasView, renderAliasList, buildAliasesDoc, loadDeps };
@@ -194,7 +194,7 @@ async function runCheck(args) {
194
194
  // empty namespace explains stale/absent aliases downstream, and staying
195
195
  // silent about it is the original defect.
196
196
  for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
197
- const driftLines = buildFallbackDriftReport(catalog);
197
+ const driftLines = buildFallbackDriftReport(catalogInfo);
198
198
  if (stale.length === 0 && drifted.length === 0) {
199
199
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
200
200
  } else if (stale.length > 0) {
@@ -202,7 +202,11 @@ async function runCheck(args) {
202
202
  process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
203
203
  if (s.suggestions.length > 0) {
204
204
  process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
205
- process.stdout.write(` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]}\n`);
205
+ // #238 D4: a user-config row is reviewable in the picker; a shipped pin
206
+ // that went stale can only be pinned OVER until the next release.
207
+ process.stdout.write(s.source === 'user-config'
208
+ ? ' fix: amicus aliases --review\n'
209
+ : ` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]} (pins over the stale shipped default)\n`);
206
210
  } else {
207
211
  process.stdout.write(' no same-vendor candidates in catalog\n');
208
212
  }
@@ -210,7 +214,7 @@ async function runCheck(args) {
210
214
  }
211
215
  for (const dr of drifted) {
212
216
  process.stdout.write(`DRIFTED: ${dr.alias} -> ${dr.stored} (stored; current resolution: ${dr.current})\n`);
213
- process.stdout.write(` stored aliases don't follow catalog updates — refresh: amicus setup --add-alias ${dr.alias}=${dr.current}\n`);
217
+ process.stdout.write(' stored aliases don\'t follow catalog updates — review: amicus aliases --review\n');
214
218
  }
215
219
  if (driftLines.length > 0) {
216
220
  process.stdout.write('Pinned fallback drift:\n');
@@ -233,12 +237,21 @@ async function runCheck(args) {
233
237
 
234
238
  /**
235
239
  * Non-blocking drift report: pinned family fallbacks vs live resolution.
236
- * Empty catalog [] (cannot check). Never affects the exit code.
237
- * @param {Array<{id:string}>} catalog
240
+ * Accepts a catalogInfo (`{models, providerFailures}`) or a bare models array
241
+ * (older callers). Empty catalog → [] (cannot check). #238 §5: when the
242
+ * openrouter namespace itself was REJECTED this run, the catalog is missing the
243
+ * rows that make a pin look current, and a drift line computed from it would
244
+ * propose a downgrade — so the report is empty for that catalog. Never affects
245
+ * the exit code.
246
+ * @param {{models: Array<{id:string}>, providerFailures?: Array<{provider:string}>}|Array<{id:string}>} catalogOrInfo
238
247
  * @returns {string[]} human-readable warning lines
239
248
  */
240
- function buildFallbackDriftReport(catalog) {
241
- if (!catalog || catalog.length === 0) { return []; }
249
+ function buildFallbackDriftReport(catalogOrInfo) {
250
+ const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
251
+ const catalog = info.models || [];
252
+ if (catalog.length === 0) { return []; }
253
+ const failures = Array.isArray(info.providerFailures) ? info.providerFailures : [];
254
+ if (failures.some(f => f && f.provider === 'openrouter')) { return []; }
242
255
  const lines = [];
243
256
  for (const f of getFamilies()) {
244
257
  const live = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
@@ -42,7 +42,7 @@ function addAlias(name, modelString) {
42
42
  /**
43
43
  * Ensure a config exists with the chosen default model. Read-modify-write:
44
44
  * preserves every pre-existing top-level key (aliases, councils, …) and only
45
- * fills in the default + any missing default aliases. Never clobbers.
45
+ * fills in the default aliases are left untouched (absence follows, #238 Q9).
46
46
  * @param {string} defaultModel - Default model alias or full model string
47
47
  * @returns {object} The resulting config object
48
48
  */
@@ -51,17 +51,12 @@ function createDefaultConfig(defaultModel) {
51
51
  const cfg = {
52
52
  ...existing,
53
53
  default: existing.default || defaultModel,
54
- // Same restatement as the readline gate below (fix round 3, G-2): a spread
55
- // into `{}` re-materialises Object.prototype. `saveConfig` rebuilds this
56
- // into its own literal anyway, so this one is defense in depth rather than
57
- // a measured hole — recorded as such rather than claimed as a fix.
58
- aliases: { __proto__: null, ...getDefaultAliases(), ...(existing.aliases || {}) },
54
+ // #238 Q9: no seeding. A curated alias FOLLOWS the shipped pin by being
55
+ // ABSENT from config.aliases (D1); only what the user chose is written.
56
+ aliases: { __proto__: null, ...(existing.aliases || {}) },
59
57
  };
60
58
  saveConfig(cfg);
61
- logger.info('Default config ensured', {
62
- default: cfg.default,
63
- aliasCount: Object.keys(cfg.aliases).length,
64
- });
59
+ logger.info('Default config ensured', { default: cfg.default, aliasCount: Object.keys(cfg.aliases).length });
65
60
  return cfg;
66
61
  }
67
62
 
@@ -524,7 +519,8 @@ async function runReadlineSetup() {
524
519
  return;
525
520
  }
526
521
 
527
- const { resolveQuickPicks, toLiveSeedAliases, toStorableRoute } = require('../utils/quick-picks');
522
+ const { resolveQuickPicks, toStorableRoute } = require('../utils/quick-picks');
523
+ const { stripGatewayPrefix } = require('../utils/curated-models');
528
524
  const picks = resolveQuickPicks(catalog);
529
525
 
530
526
  console.log('Choose your default model:');
@@ -545,7 +541,7 @@ async function runReadlineSetup() {
545
541
  }
546
542
 
547
543
  // Read-modify-write — never rebuild the alias table (no-clobber rule).
548
- const cfg = loadConfig() || { aliases: toLiveSeedAliases({ models: catalog, providerFailures }) };
544
+ const cfg = loadConfig() || { aliases: {} };
549
545
  if (!cfg.aliases) { cfg.aliases = {}; }
550
546
  if (chosen.alias) {
551
547
  cfg.default = chosen.alias;
@@ -555,11 +551,16 @@ async function runReadlineSetup() {
555
551
  // pointing at that alias name is fine (the user's explicit overall-default
556
552
  // choice), but the alias's VALUE must stay the vendor phase's tier choice --
557
553
  // skip the curated-flagship upgrade so it isn't discarded.
554
+ // #238 Q9: write the chosen default's LIVE pick only when it differs
555
+ // from the shipped pin, and say so — a pin the user was told about.
556
+ // Otherwise leave the key alone: absent = follows (D1).
558
557
  if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
559
- cfg.aliases[chosen.alias] = toStorableRoute(pick, { models: catalog, providerFailures });
560
- } else if (cfg.aliases[chosen.alias] === undefined) {
561
- const fallback = getDefaultAliases()[chosen.alias];
562
- if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
558
+ const live = toStorableRoute(pick, { models: catalog, providerFailures });
559
+ const shipped = getDefaultAliases()[chosen.alias];
560
+ if (live && stripGatewayPrefix(live) !== stripGatewayPrefix(shipped)) {
561
+ cfg.aliases[chosen.alias] = live;
562
+ console.log(`${chosen.alias} → ${live} (live flagship differs from the shipped ${shipped} — pinned)`);
563
+ }
563
564
  }
564
565
 
565
566
  // #138: offer the family -> model second level. `pick.vendorPath` is
@@ -608,7 +609,7 @@ async function runReadlineSetup() {
608
609
  const shortlist = buildModelShortlist(pick.vendorPath, {
609
610
  catalog,
610
611
  providerFailures,
611
- recommendedId: cfg.aliases[chosen.alias],
612
+ recommendedId: cfg.aliases[chosen.alias] || getDefaultAliases()[chosen.alias],
612
613
  });
613
614
  const specific = await promptForVendorModel(
614
615
  askQuestion.bind(null, rl), console.log, shortlist, pick.vendorPath
@@ -626,7 +627,8 @@ async function runReadlineSetup() {
626
627
 
627
628
  console.log('');
628
629
  console.log(`Default model set to: ${cfg.default}`);
629
- console.log(`Config saved (${Object.keys(cfg.aliases).length} aliases).`);
630
+ const pinned = Object.keys(cfg.aliases).length;
631
+ console.log(`Config saved (${pinned} pinned alias${pinned === 1 ? '' : 'es'}; the rest follow the shipped recommendations — amicus aliases).`);
630
632
  console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
631
633
 
632
634
  // C8: compact doctor summary, best-effort (see printDoctorFinale).
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @module utils/alias-groups
3
+ * Vendor-derived alias grouping (issue 213). Moved VERBATIM out of
4
+ * `electron/setup-ui-alias-groups.js` (#238 PR1 fix wave F5): `src/` code
5
+ * (src/sidecar/aliases.js — the CLI `amicus aliases` list) must not require
6
+ * from `electron/`, a layering violation the whole-branch review caught.
7
+ * `electron/setup-ui-alias-groups.js` re-exports every symbol here so its
8
+ * own callers (setup-ui-aliases.js, setup-ui.js) and their tests keep
9
+ * working untouched.
10
+ *
11
+ * REUSE NOTE: the vendor parse is `vendorOf` from src/sidecar/fallback-chains.js
12
+ * — the existing primitive, imported, not re-implemented. It PARSES a vendor
13
+ * segment (it never emits an id that gets called), which is the same
14
+ * ban-exempt category as the other allowlisted `vendorOf` callers in
15
+ * .eslintrc.js. `groupModelsByFamily` (src/utils/model-fetcher.js) is
16
+ * deliberately NOT reused: it keys on `id.split('/')[0]`, so every
17
+ * `openrouter/...` alias would collapse into a single "OpenRouter" bucket —
18
+ * exactly the grouping this file exists to avoid. Its DISPLAY half
19
+ * (PROVIDER_FAMILY_NAMES) is reused below.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const { vendorOf } = require('../sidecar/fallback-chains');
25
+ const { PROVIDER_FAMILY_NAMES, listDirectProviders } = require('./provider-registry');
26
+
27
+ /**
28
+ * Display names for vendors seen in alias routes.
29
+ *
30
+ * DISPLAY ONLY — deliberately not folded into provider-registry's PROVIDERS,
31
+ * which is a *capability* registry (env var, direct-vs-gateway, live fetch).
32
+ * KNOWN_PROVIDERS / PROVIDER_ENV_MAP are derived from that list, so adding
33
+ * `z-ai` there would claim Amicus can hold a z-ai API key. The five real
34
+ * providers keep their single source of truth via PROVIDER_FAMILY_NAMES.
35
+ * Module-private: nothing outside this file requires it directly.
36
+ */
37
+ const ALIAS_VENDOR_LABELS = {
38
+ ...PROVIDER_FAMILY_NAMES,
39
+ // Vendors reachable through the gateway (curated + commonly pinned)
40
+ 'qwen': 'Qwen',
41
+ 'mistralai': 'Mistral AI',
42
+ 'z-ai': 'Z.AI',
43
+ 'minimax': 'MiniMax',
44
+ 'x-ai': 'xAI',
45
+ 'moonshotai': 'Moonshot AI',
46
+ 'bytedance-seed': 'ByteDance Seed',
47
+ 'thinkingmachines': 'Thinking Machines',
48
+ 'cognitivecomputations': 'Cognitive Computations',
49
+ 'inclusionai': 'InclusionAI',
50
+ 'nvidia': 'NVIDIA',
51
+ 'cohere': 'Cohere',
52
+ 'meta-llama': 'Meta Llama',
53
+ 'nousresearch': 'Nous Research',
54
+ 'perplexity': 'Perplexity',
55
+ 'microsoft': 'Microsoft',
56
+ 'ai21': 'AI21',
57
+ 'amazon': 'Amazon',
58
+ // Local providers (src/utils/local-providers.js PRESETS / VALID_FLAVORS)
59
+ 'ollama': 'Ollama',
60
+ 'lmstudio': 'LM Studio',
61
+ 'vllm': 'vLLM',
62
+ };
63
+
64
+ /**
65
+ * `some-new-vendor` -> `Some New Vendor`, so an unmapped vendor is not a raw slug.
66
+ * @param {string} vendor
67
+ * @returns {string}
68
+ */
69
+ function titleCaseVendor(vendor) {
70
+ return String(vendor).split(/[-_]/).filter(Boolean)
71
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
72
+ }
73
+
74
+ /**
75
+ * Vendor key for an alias route. Wraps the shared `vendorOf` with the two
76
+ * normalisations issue 213 flagged: case, and the leading `~` of a floating
77
+ * OpenRouter id (`openrouter/~z-ai/glm-latest` must not form a second group
78
+ * next to `z-ai`).
79
+ * @param {string} route @returns {string} '' when there is no usable route
80
+ */
81
+ function aliasVendorOf(route) {
82
+ const v = vendorOf(route).toLowerCase();
83
+ return v.charAt(0) === '~' ? v.slice(1) : v;
84
+ }
85
+
86
+ /**
87
+ * Display label for a vendor key.
88
+ * hasOwnProperty, not a bare lookup: vendor is derived from a user-editable
89
+ * route, and `__proto__`/`constructor` would otherwise return prototype junk.
90
+ * @param {string} vendor @returns {string}
91
+ */
92
+ function vendorLabel(vendor) {
93
+ if (!vendor) { return 'Other'; }
94
+ const hit = Object.prototype.hasOwnProperty.call(ALIAS_VENDOR_LABELS, vendor)
95
+ ? ALIAS_VENDOR_LABELS[vendor] : null;
96
+ return hit || titleCaseVendor(vendor);
97
+ }
98
+
99
+ /** Direct-route vendors render first; everything else sorts by label. @type {string[]} */
100
+ const PREFERRED_VENDOR_ORDER = listDirectProviders();
101
+
102
+ /**
103
+ * Bucket an alias map by route vendor.
104
+ * INVARIANT: every own key of `aliases` lands in exactly one returned group —
105
+ * there is no whitelist to miss, and the empty vendor is a real catch-all.
106
+ * Order within a group follows the config's own key order.
107
+ * @param {Object<string,string>} aliases
108
+ * @returns {Array<{vendor: string, label: string, keys: string[]}>}
109
+ */
110
+ function groupAliases(aliases) {
111
+ const byVendor = new Map();
112
+ for (const key of Object.keys(aliases || {})) {
113
+ const vendor = aliasVendorOf(aliases[key]);
114
+ if (!byVendor.has(vendor)) { byVendor.set(vendor, []); }
115
+ byVendor.get(vendor).push(key);
116
+ }
117
+ const rank = (vendor) => {
118
+ if (!vendor) { return Number.MAX_SAFE_INTEGER; } // catch-all group last
119
+ const i = PREFERRED_VENDOR_ORDER.indexOf(vendor);
120
+ return i === -1 ? PREFERRED_VENDOR_ORDER.length : i;
121
+ };
122
+ return Array.from(byVendor.entries())
123
+ .map(([vendor, keys]) => ({ vendor, label: vendorLabel(vendor), keys }))
124
+ .sort((a, b) => rank(a.vendor) - rank(b.vendor) ||
125
+ a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
126
+ }
127
+
128
+ module.exports = { groupAliases, aliasVendorOf, vendorLabel, titleCaseVendor, PREFERRED_VENDOR_ORDER };