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,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 };
|
package/src/utils/config.js
CHANGED
|
@@ -77,7 +77,10 @@ function saveConfig(configData) {
|
|
|
77
77
|
}
|
|
78
78
|
cleaned[key] = value;
|
|
79
79
|
}
|
|
80
|
-
|
|
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
|
|
191
|
-
|
|
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(
|
|
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
|
}
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const { getFamilies,
|
|
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
|
-
|
|
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
|
|
96
|
-
// config.aliases
|
|
97
|
-
// not be a blind prefix strip.
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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,
|
|
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
|
};
|