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,151 @@
1
+ /**
2
+ * @module utils/alias-proposals
3
+ * The alias review ENGINE (#238 §2): pure, no I/O, no prompts. Turns
4
+ * (config.aliases, DEFAULT_ALIASES, catalogInfo, dismissed, retired, notable)
5
+ * into ONE proposal per alias (Q4), for the CLI picker and the Electron
6
+ * "Needs review" section to render and for their sinks to write.
7
+ *
8
+ * Only PINNED aliases propose (D1): a following alias resolves to the shipped
9
+ * pin and cannot drift. The §5 DISPLAY gate is applied here: candidates never
10
+ * come from a non-authoritative row or a rejected namespace, and a sibling
11
+ * (model-id-siblings.js) is always strictly newer. A stale pin's
12
+ * `replacement` candidates are ranked by similarity (not recency) and sit
13
+ * after `follow` for a curated alias. A replacement never repeats an id
14
+ * already listed as `follow`. The WRITE gate (a fresh catalog) is the
15
+ * renderer's, at accept time.
16
+ *
17
+ * Own keys only: a `__proto__`/`toString` alias is a custom row here as it is
18
+ * everywhere else in the alias tables.
19
+ *
20
+ * `retired` and `notable` are inputs `amicus aliases` does not supply yet:
21
+ * Phase 2 (`curated-pins.json`) ships the retirement data and Phase 4 the
22
+ * notable list (spec §7); the paths are built and tested here so they are
23
+ * not written twice, and are inert from the CLI until then (council r2, D3).
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const { listAliasRows } = require('./alias-state');
29
+ const { newestSibling } = require('./model-id-siblings');
30
+ const { findStaleAliases, suggestReplacements } = require('./alias-audit');
31
+ const { stripGatewayPrefix } = require('./curated-models');
32
+
33
+ const own = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
34
+ const providerOf = (id) => (typeof id === 'string' ? id.split('/')[0] : '');
35
+ const sameModel = (a, b) => typeof a === 'string' && typeof b === 'string' && stripGatewayPrefix(a) === stripGatewayPrefix(b);
36
+
37
+ /** §5 rules 1–2: rows a proposal may name. */
38
+ function candidateRows(models, failures) {
39
+ return models.filter(m => m && typeof m.id === 'string' && m.authoritative !== false && !failures.has(providerOf(m.id)));
40
+ }
41
+
42
+ /**
43
+ * The §5 display gate's id set, exposed for the picker (#249 r1 R2): a typed
44
+ * "choose another" id that names a real catalog row must still fail if that
45
+ * row is a floor entry (`authoritative: false`) or sits in a rejected
46
+ * namespace -- the numbered menu would never have offered it as a candidate
47
+ * either. Reuses `candidateRows` so the two paths can never disagree.
48
+ * @param {{models?: Array, providerFailures?: Array}|null} catalogInfo
49
+ * @returns {string[]} ids of every row the §5 display gate allows as a candidate
50
+ */
51
+ function gatedCatalogIds(catalogInfo) {
52
+ if (!catalogInfo || typeof catalogInfo !== 'object') { return []; }
53
+ const models = Array.isArray(catalogInfo.models) ? catalogInfo.models : [];
54
+ const rawFailures = catalogInfo.providerFailures;
55
+ const failures = new Set((Array.isArray(rawFailures) ? rawFailures : []).map(f => f && f.provider).filter(Boolean));
56
+ return candidateRows(models, failures).map(m => m.id);
57
+ }
58
+
59
+ function proposeForRow(r, ctx) {
60
+ if (r.state !== 'pinned' || own(ctx.retired, r.alias)) { return null; }
61
+ if (ctx.failures.has(providerOf(r.id))) { return null; } // its namespace cannot be judged
62
+ // Staleness is judged against the RAW catalog on purpose (council r2,
63
+ // B1/D4): the §5 gate governs what is PROPOSED, not what is condemned --
64
+ // on a keyless machine the only Anthropic rows are the hardcoded floor,
65
+ // and judging liveness against the gated set would call every working
66
+ // Anthropic pin stale and propose a replacement for each. A floor row is
67
+ // not evidence the provider serves a model; it is not evidence it
68
+ // stopped, either.
69
+ const stale = findStaleAliases([{ alias: r.alias, model: r.id, source: 'user-config' }], ctx.models).length === 1;
70
+ const sibling = newestSibling(r.id, ctx.candidateIds);
71
+ // Order (fix round 1, Finding 3): sibling first, UNLESS it is the shipped
72
+ // model itself — `follow` already names that id, so listing it twice as
73
+ // both "newer-sibling" and "follow" would be a display duplicate. `follow`
74
+ // outranks similarity-ranked replacements: a human-curated shipped pin is
75
+ // a better answer than a same-vendor guess, and a sibling was already
76
+ // found (even if hidden here as identical to `follow`) means the
77
+ // structurally-aware comparator has already answered "what's newer" —
78
+ // replacements are only offered when that comparator found nothing at all.
79
+ const differs = r.curated && !sameModel(r.shipped, r.id);
80
+ // F2: a sibling identical to the shipped id is not a DISTINCT candidate (it
81
+ // never reaches `candidates` below), so it must not be named in `reasons`
82
+ // either — reasons describes what was actually offered, not every signal
83
+ // the engine looked at.
84
+ const siblingIsCandidate = !!sibling && !sameModel(sibling, r.shipped);
85
+ const reasons = [];
86
+ if (stale) { reasons.push('stale'); }
87
+ if (siblingIsCandidate) { reasons.push('newer-sibling'); }
88
+ if (differs) { reasons.push('differs-from-shipped'); }
89
+ if (reasons.length === 0) { return null; }
90
+ const candidates = [];
91
+ if (siblingIsCandidate) { candidates.push({ id: sibling, why: 'newer-sibling', evidence: {} }); }
92
+ if (differs) { candidates.push({ id: r.shipped, why: 'follow', evidence: {} }); }
93
+ if (stale && !sibling) {
94
+ // Fix round 2 (ruling: DEDUPE): a replacement never repeats an id
95
+ // already listed (in practice, `follow`'s) — the same id under two
96
+ // rationales reads as a picker bug. `suggestReplacements` still caps its
97
+ // OWN output at up to 3; dropping a duplicate here can leave fewer than
98
+ // 3, never more (no backfill).
99
+ const seen = new Set(candidates.map(c => c.id));
100
+ for (const id of suggestReplacements(r.id, ctx.candidates)) {
101
+ if (!seen.has(id)) { candidates.push({ id, why: 'replacement', evidence: {} }); }
102
+ }
103
+ }
104
+ const dismissKey = `${r.alias}@${candidates.length ? candidates[0].id : r.id}`;
105
+ if (own(ctx.dismissed, dismissKey)) { return null; }
106
+ return { alias: r.alias, state: 'pinned', current: r.id, shipped: r.shipped, curated: r.curated, reasons, candidates, dismissKey };
107
+ }
108
+
109
+ function proposeNotable(entry, ctx) {
110
+ if (!entry || typeof entry.id !== 'string' || typeof entry.suggestedAlias !== 'string') { return null; }
111
+ if (own(ctx.retired, entry.suggestedAlias) || !ctx.candidateIds.includes(entry.id)) { return null; }
112
+ // Fix round 1, Finding 1: a notable must not shadow an alias NAME that
113
+ // already exists (curated or user, pinned or following) — rule 10 checked
114
+ // only the model, not the name, so a notable naming an already-pinned or
115
+ // already-taken alias produced a second, conflicting proposal for the same
116
+ // name (an 'unmapped' one beside the real 'pinned'/'following' one, in the
117
+ // worst case sharing a dismissKey with it).
118
+ if (ctx.names.has(entry.suggestedAlias)) { return null; }
119
+ if (ctx.mapped.some(id => sameModel(id, entry.id))) { return null; }
120
+ const dismissKey = `${entry.suggestedAlias}@${entry.id}`;
121
+ if (own(ctx.dismissed, dismissKey)) { return null; }
122
+ return { alias: entry.suggestedAlias, state: 'unmapped', current: null, shipped: null, curated: false,
123
+ reasons: ['notable-unmapped'], candidates: [{ id: entry.id, why: 'notable', evidence: { note: entry.note || '' } }], dismissKey };
124
+ }
125
+
126
+ /**
127
+ * @param {{userAliases: object|null, defaults: object, catalogInfo: {models?: Array, providerFailures?: Array}|null,
128
+ * retired?: object, notable?: Array<{id:string, suggestedAlias:string, note?:string}>, dismissed?: object}} input
129
+ * @returns {Array<object>} proposals — see the module docblock for the shape
130
+ */
131
+ function buildAliasProposals({ userAliases, defaults, catalogInfo, retired = {}, notable = [], dismissed = {} }) {
132
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
133
+ if (models.length === 0 || !defaults) { return []; }
134
+ // Fix round 1, Finding 2 (rule 11 "never throws on odd input"): a truthy
135
+ // non-array providerFailures (e.g. `{}`) must not reach `.map`.
136
+ const rawFailures = catalogInfo.providerFailures;
137
+ const failures = new Set((Array.isArray(rawFailures) ? rawFailures : []).map(f => f && f.provider).filter(Boolean));
138
+ const candidates = candidateRows(models, failures);
139
+ const rows = listAliasRows(userAliases, defaults);
140
+ const ctx = {
141
+ models, failures, candidates, candidateIds: candidates.map(m => m.id),
142
+ retired: retired || {}, dismissed: dismissed || {},
143
+ mapped: rows.map(r => r.id), names: new Set(rows.map(r => r.alias)),
144
+ };
145
+ const out = [];
146
+ for (const r of rows) { const p = proposeForRow(r, ctx); if (p) { out.push(p); } }
147
+ for (const n of (Array.isArray(notable) ? notable : [])) { const p = proposeNotable(n, ctx); if (p) { out.push(p); } }
148
+ return out;
149
+ }
150
+
151
+ module.exports = { buildAliasProposals, gatedCatalogIds };
@@ -39,7 +39,7 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
39
39
  }
40
40
  throw new Error(
41
41
  `Alias '${alias}' is configured but has no model value. ` +
42
- `Fix with: amicus setup --add-alias ${alias}=provider/model`
42
+ `Fix with: amicus aliases --review, or amicus setup --add-alias ${alias}=provider/model`
43
43
  );
44
44
  }
45
45
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module utils/alias-state
3
+ * Following-vs-pinned state for model aliases (#238 D1) and the normalization
4
+ * that keeps config.json truthful (D6).
5
+ *
6
+ * A curated alias FOLLOWS the shipped pin when its name is ABSENT from
7
+ * `config.aliases` — `config.js :: getEffectiveAliases` already merges
8
+ * `{...DEFAULT_ALIASES, ...userAliases}`, so absence resolves to the shipped
9
+ * id on every consumer. A present key is a PIN. Downgrade-safe in the narrow
10
+ * sense: an older amicus reads a normalized config without error and
11
+ * honours every present key (a pin) unchanged; the aliases that FOLLOW
12
+ * resolve to that older binary's shipped pins — following means tracking
13
+ * whichever binary runs.
14
+ *
15
+ * Normalization drops any key whose value equals the shipped default, with one
16
+ * Notice per key. It runs inside `saveConfig` (so every write converges) and on
17
+ * entry to `amicus aliases`. It is idempotent and never a startup write.
18
+ *
19
+ * Own keys only, everywhere: a `toString`/`constructor` name in a user
20
+ * config is a plain custom alias, never a curated one; a `__proto__` key
21
+ * gets no row at all — `saveConfig` can never persist it.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
27
+
28
+ /** @param {string} alias @param {object} defaults @returns {boolean} */
29
+ function isCurated(alias, defaults) {
30
+ return !!defaults && typeof alias === 'string' && own(defaults, alias);
31
+ }
32
+
33
+ /**
34
+ * @param {object} aliases user `config.aliases`
35
+ * @param {object} defaults the shipped map (`DEFAULT_ALIASES`)
36
+ * @param {(line: string) => void} [notify] one call per removed key
37
+ * @returns {{aliases: object, removed: Array<{alias: string, id: string}>}}
38
+ */
39
+ function normalizeAliases(aliases, defaults, notify) {
40
+ // Null-prototype (not plain `{}`): `out['__proto__'] = value` on a plain
41
+ // object hits the inherited accessor setter and is silently LOST -- the
42
+ // same footgun `saveConfig`'s pre-existing stripper documents for `cleaned`.
43
+ // Not reachable from `saveConfig` today (that stripper rejects `__proto__`
44
+ // before this function ever sees it), but this module is also entered
45
+ // directly from `amicus aliases` on raw config, so `out` must be safe on
46
+ // its own. `JSON.stringify` serializes a null-prototype object's own keys
47
+ // exactly like a plain one, so returning it as-is is safe for saveConfig.
48
+ const out = { __proto__: null };
49
+ const removed = [];
50
+ if (!aliases || typeof aliases !== 'object') { return { aliases: out, removed }; }
51
+ for (const [alias, value] of Object.entries(aliases)) {
52
+ if (typeof value === 'string' && isCurated(alias, defaults) && defaults[alias] === value) {
53
+ removed.push({ alias, id: value });
54
+ if (typeof notify === 'function') {
55
+ notify(`Notice: alias '${alias}' matches the shipped recommendation (${value}) — now following\n`);
56
+ }
57
+ continue;
58
+ }
59
+ out[alias] = value;
60
+ }
61
+ return { aliases: out, removed };
62
+ }
63
+
64
+ /**
65
+ * @param {object|null} userAliases
66
+ * @param {object} defaults
67
+ * @returns {Array<{alias:string,id:string,state:'following'|'pinned',curated:boolean,shipped:string|null}>}
68
+ */
69
+ function listAliasRows(userAliases, defaults) {
70
+ const user = (userAliases && typeof userAliases === 'object') ? userAliases : {};
71
+ const rows = [];
72
+ for (const alias of Object.keys(defaults || {})) {
73
+ const pinned = own(user, alias) && typeof user[alias] === 'string';
74
+ rows.push({ alias, id: pinned ? user[alias] : defaults[alias], state: pinned ? 'pinned' : 'following',
75
+ curated: true, shipped: defaults[alias] });
76
+ }
77
+ for (const alias of Object.keys(user)) {
78
+ // #249 r1 R8b: '__proto__' can never be persisted (saveConfig's own
79
+ // stripper rejects it, config.js :: saveConfig) -- a row for it here
80
+ // would show state the user can never actually reach, so it gets no
81
+ // row and no proposal.
82
+ if (alias === '__proto__' || isCurated(alias, defaults) || typeof user[alias] !== 'string') { continue; }
83
+ rows.push({ alias, id: user[alias], state: 'pinned', curated: false, shipped: null });
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ module.exports = { normalizeAliases, listAliasRows, isCurated };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @module utils/alias-store
3
+ * The write sinks the alias review flow needs beyond `setup.js :: addAlias`
4
+ * (#238 §2). Every write is read-modify-write through `saveConfig` (which
5
+ * normalizes, D6) and preserves every other key — the no-clobber contract of
6
+ * `provider-default-picker.js :: applyProviderDefault`.
7
+ *
8
+ * `removeAlias` is BOTH "unpin" (a curated name resurrects from the defaults)
9
+ * and "delete" (a user-invented name is gone) — the meaning is decided by
10
+ * whether the name is curated, not by this module (D1).
11
+ *
12
+ * Dismissals are keyed `alias@proposedId` (Q5): permanent for that pair, and a
13
+ * newer proposed id for the same alias is a new key that asks again.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
19
+
20
+ /**
21
+ * @param {string} alias
22
+ * @returns {boolean} true when a key was removed and the config saved
23
+ */
24
+ function removeAlias(alias) {
25
+ if (typeof alias === 'string') { alias = alias.trim(); }
26
+ if (!alias || typeof alias !== 'string' || alias === 'null') {
27
+ throw new Error(`Invalid alias name: '${alias}'. Alias name must be a non-empty string.`);
28
+ }
29
+ const { loadConfig, saveConfig } = require('./config');
30
+ const config = loadConfig();
31
+ if (!config || !config.aliases || typeof config.aliases !== 'object' || !own(config.aliases, alias)) { return false; }
32
+ delete config.aliases[alias];
33
+ saveConfig(config);
34
+ return true;
35
+ }
36
+
37
+ /** @returns {Object<string,string>} null-prototype copy of aliasReview.dismissed */
38
+ function readDismissals() {
39
+ const { loadConfig } = require('./config');
40
+ const config = loadConfig();
41
+ const out = { __proto__: null };
42
+ const d = config && config.aliasReview && typeof config.aliasReview === 'object' ? config.aliasReview.dismissed : null;
43
+ if (d && typeof d === 'object') {
44
+ for (const [k, v] of Object.entries(d)) { if (typeof v === 'string') { out[k] = v; } }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * @param {string} dismissKey `alias@proposedId`
51
+ * @param {Date} [now]
52
+ */
53
+ function recordDismissal(dismissKey, now = new Date()) {
54
+ if (typeof dismissKey !== 'string' || !dismissKey || !dismissKey.includes('@')) {
55
+ throw new Error(`Invalid dismissKey '${dismissKey}': expected alias@proposedId`);
56
+ }
57
+ const { loadConfig, saveConfig } = require('./config');
58
+ const config = loadConfig() || {};
59
+ if (!config.aliasReview || typeof config.aliasReview !== 'object') { config.aliasReview = {}; }
60
+ if (!config.aliasReview.dismissed || typeof config.aliasReview.dismissed !== 'object') { config.aliasReview.dismissed = {}; }
61
+ config.aliasReview.dismissed[dismissKey] = now.toISOString();
62
+ saveConfig(config);
63
+ }
64
+
65
+ module.exports = { removeAlias, readDismissals, recordDismissal };
@@ -77,7 +77,10 @@ function saveConfig(configData) {
77
77
  }
78
78
  cleaned[key] = value;
79
79
  }
80
- configData.aliases = cleaned;
80
+ // #238 D6: a key equal to the shipped default is the same as absence —
81
+ // drop it so the alias FOLLOWS the next pin bump (one Notice per key).
82
+ const { normalizeAliases } = require('./alias-state');
83
+ configData.aliases = normalizeAliases(cleaned, DEFAULT_ALIASES, (line) => process.stderr.write(line)).aliases;
81
84
  }
82
85
  const configDir = getConfigDir();
83
86
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
@@ -187,9 +190,11 @@ function computeConfigHash() {
187
190
  /** @returns {string} Markdown alias table with (default) marker, or empty string */
188
191
  function buildAliasTable() {
189
192
  const config = loadConfig();
190
- if (!config || !config.aliases || Object.keys(config.aliases).length === 0) {
191
- return '';
192
- }
193
+ if (!config) { return ''; }
194
+ // #238 D6: EFFECTIVE aliases — a following alias is absent from config.aliases
195
+ // but is still an alias the project's CLAUDE.md block must list.
196
+ const aliases = getEffectiveAliases();
197
+ if (Object.keys(aliases).length === 0) { return ''; }
193
198
 
194
199
  const defaultAlias = config.default || null;
195
200
  const lines = [];
@@ -197,7 +202,7 @@ function buildAliasTable() {
197
202
  lines.push('| Alias | Model |');
198
203
  lines.push('|-------|-------|');
199
204
 
200
- for (const [alias, model] of Object.entries(config.aliases)) {
205
+ for (const [alias, model] of Object.entries(aliases)) {
201
206
  const marker = (alias === defaultAlias) ? ' (default)' : '';
202
207
  lines.push(`| ${alias}${marker} | ${model} |`);
203
208
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @module utils/model-id-siblings
3
+ * The tier-safe sibling comparator (#238 D7). A sibling shares a pin's vendor
4
+ * path, its pre-version prefix and its post-version suffix, and differs only in
5
+ * the version number — so `gpt-5.6-terra` is never compared against
6
+ * `gpt-5.6-sol`, and `kimi-k3` never against `kimi-k2.7-code`. Lifted verbatim
7
+ * from scripts/check-ci-alias-pins.js (which still consumes it) so the alias
8
+ * review engine (alias-proposals.js) asks the user's pinned ids the SAME sibling
9
+ * question the CI drift gate asks the CI alias map. `amicus models --check`
10
+ * asks a DIFFERENT question of the same shipped pins — still-listed vs.
11
+ * gone-from-the-catalog — through `alias-audit.js :: findStaleAliases`, not
12
+ * this module (#249 r2 review F2: this line previously implied otherwise).
13
+ *
14
+ * Known limits, both under-report by design (a suppressed sibling, never a
15
+ * manufactured one) — INVARIANT: neither rule can ever turn two DIFFERENT
16
+ * models into siblings, only fail to notice two of the SAME family are:
17
+ * 1. A dash-versioned id — Anthropic's `claude-opus-4-5`, say — parses its
18
+ * trailing `-5` as part of the suffix rather than the version (the
19
+ * version group only extends through a DOTTED numeric run), so two
20
+ * dash-versioned releases are never compared as siblings at all.
21
+ * 2. A numeric run glued to a following ASCII letter is a size/variant
22
+ * token, never a version (#249 r2 B2) — `parsePin` returns `null` for
23
+ * both `gpt-oss-20b` and `gpt-oss-120b` (#249 r2 review F6: a null
24
+ * parse has no prefix at all, unlike limit 1's dash-versioned case
25
+ * above), since `20`/`120` are each immediately followed by `b` and no
26
+ * other numeric-dotted run exists to fall back to. MEASURED against a
27
+ * 638-id live catalog cache: 129 ids carry a glued run (`24b`, `70b`,
28
+ * `a3b`, `8x22b`, `4o`, …) — without this rule `gpt-oss-120b` reads as
29
+ * a "newer same-tier sibling" of `gpt-oss-20b`, a different model
30
+ * wearing a bigger size, not a newer version. A run preceded by a
31
+ * letter is unaffected (`kimi-k3`, `deepseek-v4-pro`, `qwen3.8-max`
32
+ * keep parsing exactly as before) — only the character AFTER the run
33
+ * is examined.
34
+ * `scripts/check-ci-alias-pins.js`'s CI drift gate consumes `parsePin`
35
+ * unchanged, so it inherits rule 2 automatically.
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ /**
41
+ * Split a model id into the parts a sibling comparison needs.
42
+ * `openrouter/z-ai/glm-5.3` -> vendor `openrouter/z-ai`, prefix `glm-`,
43
+ * version [5,3], suffix ``. Returns null when the tail carries no numeric
44
+ * version (nothing to compare) or the id is an OpenRouter floating pointer,
45
+ * whose `~` forms name no concrete release.
46
+ * @param {string} id
47
+ * @returns {{vendor:string, prefix:string, version:number[], suffix:string}|null}
48
+ */
49
+ function parsePin(id) {
50
+ if (typeof id !== 'string' || id.includes('~')) { return null; }
51
+ const cut = id.lastIndexOf('/');
52
+ if (cut === -1) { return null; }
53
+ const vendor = id.slice(0, cut);
54
+ const tail = id.slice(cut + 1);
55
+ // Scan every numeric-dotted run left to right; the first one NOT glued to
56
+ // a following ASCII letter is the version (#249 r2 B2 -- see the module
57
+ // docblock's known limit 2). A glued run is a size/variant token (`20b`,
58
+ // `8x22b`) and is skipped, falling through to whatever comes after it --
59
+ // which, once a version is found, is everything the plain suffix already
60
+ // captured, glued runs included.
61
+ for (const m of tail.matchAll(/\d+(?:\.\d+)*/g)) {
62
+ const end = m.index + m[0].length;
63
+ if (/[A-Za-z]/.test(tail[end] || '')) { continue; }
64
+ return { vendor, prefix: tail.slice(0, m.index), version: m[0].split('.').map(Number), suffix: tail.slice(end) };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** @returns {number} >0 when a is newer than b, <0 when older, 0 when equal */
70
+ function compareVersions(a, b) {
71
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
72
+ const diff = (a[i] || 0) - (b[i] || 0);
73
+ if (diff !== 0) { return diff; }
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ /**
79
+ * @param {string} pinned a fully-qualified model id
80
+ * @param {string[]} catalogIds every id in the live catalog
81
+ * @returns {string|null} the newest strictly-newer sibling, or null
82
+ */
83
+ function newestSibling(pinned, catalogIds) {
84
+ const pin = parsePin(pinned);
85
+ if (!pin) { return null; }
86
+ let best = null;
87
+ let bestVersion = pin.version;
88
+ for (const id of catalogIds) {
89
+ // No blanket `:` skip. `:free` / `:batch` land in `suffix`, so the
90
+ // suffix equality below ALREADY refuses to bump a plain pin to a billing
91
+ // variant — while a blanket skip additionally blinded the checker to a
92
+ // pin that is ITSELF a variant (a `:free` pin could never find a `:free`
93
+ // sibling, and went quietly unwatched forever). Council finding D3.
94
+ const other = parsePin(id);
95
+ if (!other) { continue; }
96
+ if (other.vendor !== pin.vendor) { continue; }
97
+ if (other.prefix !== pin.prefix || other.suffix !== pin.suffix) { continue; }
98
+ if (compareVersions(other.version, bestVersion) > 0) {
99
+ best = id;
100
+ bestVersion = other.version;
101
+ }
102
+ }
103
+ return best;
104
+ }
105
+
106
+ module.exports = { parsePin, compareVersions, newestSibling };
@@ -159,7 +159,7 @@ async function validateAgainstCatalog(resolvedModel, alias) {
159
159
  throw new Error(
160
160
  `Model '${resolvedModel}' not found in the OpenRouter catalog.\n` +
161
161
  (list ? `Did you mean:\n${list}\n` : '') +
162
- `Fix: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'}\n` +
162
+ `Fix: amicus aliases --review (or pin directly: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'})\n` +
163
163
  'Run \'amicus models --refresh\' to update the catalog, or pass --no-validate-model to skip.'
164
164
  );
165
165
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { getFamilies, toDefaultAliases, DIVERGENT_VENDORS } = require('./curated-models');
13
+ const { getFamilies, DIVERGENT_VENDORS } = require('./curated-models');
14
14
  const { directFormIfSafe } = require('./model-canonicalization');
15
15
 
16
16
  const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
@@ -37,7 +37,11 @@ function compareIdsDesc(a, b) {
37
37
  function pickCurrent(catalog, nsPrefix, vendorPath, idPattern) {
38
38
  const prefix = `${nsPrefix}${vendorPath}/`;
39
39
  const ids = (Array.isArray(catalog) ? catalog : [])
40
- .map(m => m && m.id)
40
+ // #238 §5: a non-authoritative row (the hardcoded Anthropic floor, a
41
+ // floor-fallback) is not evidence of what the provider serves — the same
42
+ // guard gateway-route-audit.js :: isAuthoritative applies per gateway.
43
+ .filter(m => m && m.authoritative !== false)
44
+ .map(m => m.id)
41
45
  .filter(id => typeof id === 'string' && id.startsWith(prefix))
42
46
  .filter(id => idPattern.test(id.slice(prefix.length)));
43
47
  if (ids.length === 0) { return null; }
@@ -92,38 +96,15 @@ function toStorableRoute(pick, catalogInfo) {
92
96
  }
93
97
  const route = routes.openrouter || Object.values(routes)[0];
94
98
  if (!route) { return undefined; }
95
- // issue 214 remedy 1: this value is PERSISTED (sidecar/setup.js writes it into
96
- // config.aliases; toLiveSeedAliases seeds a fresh config with it), so it must
97
- // not be a blind prefix strip. directFormIfSafe keeps the optimism for a
98
- // namespace that was never fetched while refusing for one the catalog
99
- // disproves OR whose fetch was rejected -- the gap #208 closed on the picker
100
- // path and left open here.
99
+ // issue 214 remedy 1: this value is PERSISTED -- sidecar/setup.js writes it
100
+ // into config.aliases for the chosen default when it differs from the
101
+ // shipped pin (#238 Q9) -- so it must not be a blind prefix strip.
102
+ // directFormIfSafe keeps the optimism for a namespace that was never
103
+ // fetched while refusing for one the catalog disproves OR whose fetch was
104
+ // rejected -- the gap #208 closed on the picker path and left open here.
101
105
  return directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
102
106
  }
103
107
 
104
- /**
105
- * Seed map for fresh configs: static defaults overlaid with live family
106
- * routes (cardless aliases stay pinned). See `toStorableRoute` for why the
107
- * overlaid value is not a raw prefix strip.
108
- * @returns {Object<string,string>}
109
- */
110
- function toLiveSeedAliases(catalogOrInfo) {
111
- // Accepts the bare models array (historical callers) or a full catalogInfo.
112
- // issue 214: the evidence was always handed in and then discarded.
113
- const info = Array.isArray(catalogOrInfo)
114
- ? { models: catalogOrInfo }
115
- : (catalogOrInfo || { models: [] });
116
- const seeds = toDefaultAliases();
117
- for (const r of resolveQuickPicks(info.models || [])) {
118
- if (r.source === 'live' && r.routes.openrouter) {
119
- const stored = toStorableRoute(r, info);
120
- if (stored) { seeds[r.alias] = stored; }
121
- }
122
- }
123
- return seeds;
124
- }
125
-
126
-
127
108
  /**
128
109
  * Per-provider SAFE storable form for a resolved quick pick (issue 214).
129
110
  *
@@ -147,4 +128,4 @@ function canonicalRoutesFor(pick, catalogInfo) {
147
128
  return out;
148
129
  }
149
130
 
150
- module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
131
+ module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toStorableRoute };
@@ -20,6 +20,17 @@
20
20
  * that has landed here (ANSI in round 2, bidi in round 3) was a class the
21
21
  * previous pass could not see.
22
22
  *
23
+ * `safeFragment` (#249 r2 C4) is the same discipline for a shorter kind of
24
+ * text: not a sentence-length excerpt but ONE quoted config/catalog/typed
25
+ * value — an alias name, a model id, a dismiss key. MEASURED: the longest id
26
+ * in a 638-row live catalog cache is 67 characters
27
+ * (`openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition`);
28
+ * past 96 it is a payload, not an id. `utils/alias-shadow.js` keeps its OWN
29
+ * 64-char local cap (measured against the curated table alone, which never
30
+ * exceeds 39 characters) through this same `collapseExcerpt` function — one
31
+ * sanitizer, two caps sized to what each caller actually holds, no second
32
+ * dialect.
33
+ *
23
34
  * PURE: no I/O, no throwing paths, no state.
24
35
  */
25
36
 
@@ -75,7 +86,23 @@ function collapseExcerpt(text, maxChars = MAX_EXCERPT_CHARS) {
75
86
  return `${oneLine.slice(0, maxChars - 1)}…`;
76
87
  }
77
88
 
89
+ /** One quoted fragment's cap — an id/name/key, not a sentence. See the module docblock. */
90
+ const MAX_FRAGMENT_CHARS = 96;
91
+
92
+ /**
93
+ * One third-party fragment (an alias name, a model id, a dismiss key, a
94
+ * catalog note), safe to interpolate into a terminal line. Same pass as
95
+ * `collapseExcerpt`, just capped for a short value instead of a sentence.
96
+ * @param {*} value
97
+ * @returns {string}
98
+ */
99
+ function safeFragment(value) {
100
+ return collapseExcerpt(value, MAX_FRAGMENT_CHARS);
101
+ }
102
+
78
103
  module.exports = {
79
104
  collapseExcerpt,
80
105
  MAX_EXCERPT_CHARS,
106
+ safeFragment,
107
+ MAX_FRAGMENT_CHARS,
81
108
  };