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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +2 -1
- package/bin/amicus.js +5 -0
- package/docs/ROADMAP.md +32 -4
- package/docs/architecture-map.md +22 -2
- package/docs/configuration.md +14 -8
- package/docs/usage.md +22 -3
- package/electron/ipc-setup.js +6 -9
- package/electron/setup-ui-alias-groups.js +29 -124
- package/package.json +1 -1
- package/src/cli-handlers.js +8 -1
- package/src/cli.js +11 -0
- package/src/sidecar/aliases-review-gate.js +65 -0
- package/src/sidecar/aliases-review-prompt.js +91 -0
- package/src/sidecar/aliases-review-render.js +116 -0
- package/src/sidecar/aliases-review.js +298 -0
- package/src/sidecar/aliases.js +279 -0
- package/src/sidecar/models.js +20 -7
- package/src/sidecar/setup.js +20 -18
- package/src/utils/alias-groups.js +128 -0
- package/src/utils/alias-proposals.js +151 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/alias-state.js +88 -0
- package/src/utils/alias-store.js +65 -0
- package/src/utils/config.js +10 -5
- package/src/utils/model-id-siblings.js +106 -0
- package/src/utils/model-validator.js +1 -1
- package/src/utils/quick-picks.js +13 -32
- package/src/utils/text-sanitize.js +27 -0
|
@@ -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 };
|
package/src/sidecar/models.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
237
|
-
*
|
|
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(
|
|
241
|
-
|
|
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);
|
package/src/sidecar/setup.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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 };
|