amicus 4.8.0 → 4.8.1

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.
@@ -17,8 +17,11 @@
17
17
  const { resolveTier } = require('./model-tiers');
18
18
  const { getCostTier, loadConfig, saveConfig } = require('./config');
19
19
  const { pairAcrossGateways } = require('./gateway-route-catalog');
20
- const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
21
20
  const { isLocalProvider } = require('./local-providers');
21
+ // directFormIfSafe (list-building) / directFormIfProven (persistence): see
22
+ // model-canonicalization.js's module docstring for why these are two
23
+ // separately-named functions rather than one with an optional mode.
24
+ const { directFormIfSafe, directFormIfProven } = require('./model-canonicalization');
22
25
 
23
26
  /**
24
27
  * @param {{pricing?: {prompt?: string|number|null}|null}|null|undefined} orRow
@@ -49,8 +52,10 @@ function vendorRowsIn(catalog, vendor) {
49
52
  }
50
53
 
51
54
  /**
52
- * Choose the verbatim catalog id a single catalog `row` should surface as in
53
- * the picker -- NEVER fabricates an id.
55
+ * Choose the id a single catalog `row` should surface as in the picker.
56
+ * Prefers a real, verbatim catalog id; only SYNTHESISES one (delegating to
57
+ * `directFormIfSafe`, model-canonicalization.js -- see its module docstring
58
+ * for the guard) for a non-divergent vendor with no direct twin at all.
54
59
  *
55
60
  * A direct-namespace row always keeps its own (real, direct-callable) id,
56
61
  * regardless of what `pairAcrossGateways` could resolve for it -- this is
@@ -60,35 +65,55 @@ function vendorRowsIn(catalog, vendor) {
60
65
  * and must not be dropped or silently merged.
61
66
  *
62
67
  * An OpenRouter-namespace row is collapsed onto a direct id only when
63
- * `pairAcrossGateways` found exactly one (unambiguous) direct twin -- that's
64
- * a real catalog id, safe to reuse for dedup. Otherwise: for
68
+ * `pairAcrossGateways` found exactly one (unambiguous) direct twin. For
65
69
  * `DIVERGENT_VENDORS` (direct ids don't share a string form with
66
- * OpenRouter's -- e.g. anthropic's dash-vs-dot versioning), the row keeps
67
- * its OpenRouter-prefixed id as-is; stripping the prefix would fabricate a
68
- * non-direct-callable dot-form id no row actually carries. Non-divergent
69
- * vendors keep the pre-existing strip via `toCanonicalDefault`, which is
70
- * safe because their direct and OpenRouter ids are identical once the
71
- * `openrouter/<vendor>/` prefix is removed (mirrors curated-models.js's
72
- * `directFormFor` policy).
70
+ * OpenRouter's, e.g. anthropic's dash-vs-dot versioning), the row keeps its
71
+ * OpenRouter-prefixed id as-is -- `directFormIfSafe` gates that set FIRST
72
+ * and internally (model-canonicalization.js), so stripping is never even
73
+ * attempted on their ids, which is not a string-safe operation.
73
74
  * @param {string} vendor
74
75
  * @param {boolean} isDirect whether `row.id` is itself a direct-namespace id
75
76
  * @param {{id:string}} row
76
77
  * @param {{direct?:string, openrouter?:string}} paired
78
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
77
79
  * @returns {string|null}
78
80
  */
79
- function chooseRowId(vendor, isDirect, row, paired) {
81
+ function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
80
82
  if (isDirect) { return row.id; }
81
83
  if (paired.direct) { return paired.direct; }
82
84
  if (!paired.openrouter) { return null; }
83
- return DIVERGENT_VENDORS.has(vendor) ? paired.openrouter : toCanonicalDefault(paired.openrouter);
85
+ return directFormIfSafe(vendor, paired.openrouter, catalogInfo);
84
86
  }
85
87
 
86
88
  /**
87
89
  * Dedupe `vendor`'s catalog rows across gateways into one row per logical
88
90
  * model, reusing `pairAcrossGateways` (never re-deriving the pairing logic).
89
- * Every row id is verbatim from the catalog (see `chooseRowId`) -- never
90
- * fabricated, and a direct row is never dropped even when its OpenRouter
91
- * twin is ambiguous.
91
+ * A direct row is never dropped even when its OpenRouter twin is ambiguous.
92
+ *
93
+ * Row ids are verbatim catalog ids whenever a real row carries them: a
94
+ * direct row keeps its own id, and an OpenRouter row collapses onto a
95
+ * direct twin only when `pairAcrossGateways` found exactly one. For a
96
+ * NON-divergent vendor with no direct twin at all, `chooseRowId` derives
97
+ * the bare form via `directFormIfSafe` -- SYNTHESISED, not verbatim, only
98
+ * when the vendor's namespace can't prove it invalid (issue 195). Measured
99
+ * 2026-08-24 against a real 601-row catalog: all 14 `deepseek` rows derive
100
+ * this way (empty namespace); `google` (19/69) and `openai` (51/175) rows
101
+ * used to derive the same unconditional way despite a populated,
102
+ * authoritative namespace omitting that specific id, and now stay
103
+ * OpenRouter-prefixed instead. Once a bare id IS synthesized (the deepseek
104
+ * case), it is safe for routing, but not for the reason an earlier version
105
+ * of this comment claimed: a bare id is NOT failure-routed direct-first-
106
+ * with-OpenRouter-fallback. gateway-router.js's auto-mode step 7 is
107
+ * `if (rq.keys[vendor] && hasForm(rq, 'direct'))`, and `hasForm` is
108
+ * `!req.gatewayIds || req.gatewayIds[gateway] !== undefined` -- a bare id
109
+ * carries no `gatewayIds` at all, so `hasForm(rq, 'direct')` is VACUOUSLY
110
+ * true on this path; the only real gate is `rq.keys[vendor]`. The fallback
111
+ * to OpenRouter is therefore KEY-ABSENCE-driven (no key for `vendor`), not
112
+ * a check that the direct form actually resolves -- that check only fires
113
+ * on the ALIAS path, where `gatewayIds` IS attached and `hasForm` can
114
+ * genuinely be false. It is not a catalog-confirmed direct id either way,
115
+ * and callers that need that distinction should consult
116
+ * `curated-models.js`'s `directFormProvenance()`.
92
117
  * @param {Array<{id:string,name:string,contextLength:(number|null)}>} catalog
93
118
  * @param {string} vendor
94
119
  * @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
@@ -109,7 +134,7 @@ function buildRows(catalog, vendor) {
109
134
  const isDirect = row.id.startsWith(directPrefix);
110
135
  const token = isDirect ? row.id.slice(directPrefix.length) : row.id.slice(orPrefix.length);
111
136
  const paired = pairAcrossGateways(vendor, token, catalogInfo);
112
- const chosenId = chooseRowId(vendor, isDirect, row, paired);
137
+ const chosenId = chooseRowId(vendor, isDirect, row, paired, catalogInfo);
113
138
  if (!chosenId || seenIds.has(chosenId)) { continue; }
114
139
  seenIds.add(chosenId);
115
140
 
@@ -124,7 +149,7 @@ function buildRows(catalog, vendor) {
124
149
  // pricing (`sourceRow`, which for a local/direct row IS the catalog row
125
150
  // itself) instead. Gated on `isLocal`, NOT on "no OpenRouter twin", so a
126
151
  // direct (non-local) row with no twin still renders `pricePerMInput:
127
- // null` (tests/provider-default-picker.test.js:59, pinned).
152
+ // null` (tests/provider-default-picker.test.js:60, pinned).
128
153
  const localPrice = isLocal ? pricePerMInputFrom(sourceRow) : null;
129
154
 
130
155
  rows.push({
@@ -140,22 +165,22 @@ function buildRows(catalog, vendor) {
140
165
 
141
166
  /**
142
167
  * Canonicalize `resolveTier`'s verbatim catalog-id output the same way row
143
- * ids are built (`chooseRowId`), so it can be matched against `rows`
144
- * exactly. `resolveTier` already prefers a real direct id when one matches
145
- * the tier pattern (`model-tiers.js`'s `pickForTier` tries the direct
146
- * namespace first), so this only has an effect when it fell back to an
147
- * OpenRouter id (no matching direct-namespace row at all). For
148
- * `DIVERGENT_VENDORS`, that OpenRouter id must stay OpenRouter-prefixed --
149
- * stripping it would fabricate a non-direct-callable dot-form id that no row
150
- * carries. Non-divergent vendors keep the pre-existing strip, safe because
151
- * their direct and OpenRouter ids are identical once the prefix is removed.
168
+ * ids are built (`chooseRowId`/`directFormIfSafe`), so it matches `rows`
169
+ * exactly. Only matters when `resolveTier` fell back to an OpenRouter id (no
170
+ * matching direct-namespace row for the tier). `directFormIfSafe` gates
171
+ * `DIVERGENT_VENDORS` itself and keeps that id OpenRouter-prefixed;
172
+ * non-divergent vendors go through the same `classifyModel`-guarded strip
173
+ * `chooseRowId` uses (issue 195) -- an unconditional strip here would
174
+ * produce a canonical id `rows` no longer carries whenever `directFormIfSafe`
175
+ * kept the row itself OpenRouter-prefixed, defeating the match below.
152
176
  * @param {string} vendor
153
177
  * @param {string|null} resolved verbatim catalog id from `resolveTier`, or null
178
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
154
179
  * @returns {string|null}
155
180
  */
156
- function canonicalizeResolved(vendor, resolved) {
181
+ function canonicalizeResolved(vendor, resolved, catalogInfo) {
157
182
  if (!resolved) { return null; }
158
- return DIVERGENT_VENDORS.has(vendor) ? resolved : toCanonicalDefault(resolved);
183
+ return directFormIfSafe(vendor, resolved, catalogInfo);
159
184
  }
160
185
 
161
186
  /**
@@ -173,7 +198,9 @@ function canonicalizeResolved(vendor, resolved) {
173
198
  function computePreselectedId(vendor, tier, catalog, rows) {
174
199
  const effectiveTier = tier || getCostTier();
175
200
  const resolved = resolveTier(vendor, effectiveTier, catalog);
176
- const canonical = canonicalizeResolved(vendor, resolved);
201
+ // F2 (B5): guard the same way applyProviderDefault does, so classifyModel
202
+ // never sees a non-array `models` regardless of what a caller passes.
203
+ const canonical = canonicalizeResolved(vendor, resolved, { models: Array.isArray(catalog) ? catalog : [] });
177
204
  if (canonical && rows.some(r => r.id === canonical)) { return canonical; }
178
205
 
179
206
  const priced = rows.filter(r => r.pricePerMInput !== null);
@@ -217,22 +244,35 @@ function buildProviderDefaultChoices(vendor, options = {}) {
217
244
  * `config.default` on first use. Read-modify-write, NO-CLOBBER -- preserves
218
245
  * an already-set `config.default` and every other existing alias/key.
219
246
  *
220
- * `chosenId` is a verbatim catalog id straight from `buildProviderDefaultChoices`
221
- * (via `chooseRowId`/`computePreselectedId`): a real direct id, or -- for a
222
- * `DIVERGENT_VENDORS` vendor with no direct twin -- an `openrouter/`-prefixed
223
- * id. **Never** run `toCanonicalDefault` on a divergent vendor's id: that
224
- * would strip the `openrouter/` prefix and fabricate a non-direct-callable
225
- * dot-form id no row actually carries (the exact bug just fixed in Task 4's
226
- * `chooseRowId`/`canonicalizeResolved`). Non-divergent vendors' direct and
227
- * OpenRouter ids are identical once the prefix is stripped, so
228
- * `toCanonicalDefault` is safe there.
247
+ * `chosenId` comes straight from `buildProviderDefaultChoices`: a real
248
+ * direct id; an `openrouter/`-prefixed id (`DIVERGENT_VENDORS`, or -- since
249
+ * issue 195 -- a non-divergent vendor whose bare form would classify
250
+ * `invalid`); or a bare id already SYNTHESISED via `directFormIfSafe`. By
251
+ * the time this function runs, `chosenId`'s prefix (or lack of one) IS the
252
+ * decision -- PERSISTENCE therefore uses `directFormIfProven`, not
253
+ * `directFormIfSafe`: it strips the `openrouter/` prefix only on POSITIVE
254
+ * evidence (`classifyModel` returns `valid` -- the bare id is an actual
255
+ * catalog row), never merely because the catalog couldn't disprove it.
256
+ *
257
+ * This matters because `directFormIfSafe`'s optimistic default is correct
258
+ * for LIST-BUILDING (an empty/absent catalog can't assert absence, so a
259
+ * bare guess is reasonable to offer) but wrong here: a catalog fetch that
260
+ * failed or came back empty (`catalog` omitted, or `[]`) must never be read
261
+ * as license to fabricate a direct id `chooseRowId` never actually offered
262
+ * -- that reintroduces the exact bug issue 195 fixed, silently, on every
263
+ * degraded fetch. `directFormIfProven` preserves `chosenId` verbatim
264
+ * whenever the catalog can't prove the bare form valid, so an empty/absent
265
+ * `catalog` option is inert (no canonicalization at all), not a fallback to
266
+ * the old unconditional strip.
229
267
  * @param {string} vendor e.g. 'anthropic'
230
- * @param {string} chosenId verbatim catalog id from the picker
231
- * @param {{seedDefaultIfAbsent?: boolean}} [options]
268
+ * @param {string} chosenId id from the picker (see above -- not always
269
+ * catalog-verbatim)
270
+ * @param {{seedDefaultIfAbsent?: boolean, catalog?: Array<{id:string}>}} [options]
232
271
  * @returns {{alias: string, setAsDefault: boolean}}
233
272
  */
234
- function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } = {}) {
235
- const storedId = DIVERGENT_VENDORS.has(vendor) ? chosenId : toCanonicalDefault(chosenId);
273
+ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
274
+ const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
275
+ const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
236
276
 
237
277
  const config = loadConfig() || {};
238
278
  if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
@@ -246,4 +286,12 @@ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } =
246
286
  return { alias: vendor, setAsDefault };
247
287
  }
248
288
 
249
- module.exports = { buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom };
289
+ // directFormIfSafe/directFormIfProven are re-exported (not just used
290
+ // internally) so a caller that wants the canonicalization primitives
291
+ // directly doesn't need to know they now live in model-canonicalization.js
292
+ // -- see tests/model-canonicalization.test.js for the identity pin proving
293
+ // this is a re-export, not a second, divergeable copy.
294
+ module.exports = {
295
+ buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom,
296
+ directFormIfSafe, directFormIfProven,
297
+ };
@@ -111,7 +111,7 @@ async function runProviderDefaultFlow(provider, options = {}) {
111
111
  chosenId = await promptForChoice(ask, print, choices);
112
112
  }
113
113
 
114
- const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
114
+ const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
115
115
  const summaryLine = `\`amicus start --model ${provider}\` → ${chosenId}` +
116
116
  (setAsDefault ? ', set as your default model' : '');
117
117
 
@@ -79,8 +79,8 @@ function resolveQuickPicks(catalog) {
79
79
  * Stripping the prefix there fabricates an id the direct API rejects, which
80
80
  * `amicus doctor` then reports as a stale alias. The row's own direct route is
81
81
  * used verbatim, falling back to the intact `openrouter/` form when the
82
- * catalog offered no direct pick. Mirrors the guard already used at
83
- * `provider-default-picker.js:82,143,220`.
82
+ * catalog offered no direct pick. Mirrors the DIVERGENT_VENDORS-first guard
83
+ * `model-canonicalization.js :: directFormIfSafe` uses internally.
84
84
  * @param {{vendorPath?:string, routes?:Object<string,string>}} pick
85
85
  * @returns {string|undefined}
86
86
  */
@@ -143,6 +143,30 @@ const REMEDIATION_HINTS = Object.freeze({
143
143
  */
144
144
  pruneSessionIndex:
145
145
  'amicus doctor --fix (removes sessions-index.json entries whose project no longer exists on disk — liveness-based, never by age)',
146
+
147
+ /**
148
+ * Fabricated bare alias (B3, council review of PR 198 / issue 195): v4.8.0
149
+ * could persist a `<vendor>/<model>` id no catalog row carries. `doctor
150
+ * --fix` rewrites ONLY the narrow, mechanically-unambiguous class -- an
151
+ * alias that classifies `invalid` on the `direct` gateway AND has an
152
+ * unambiguous OpenRouter twin (`pairAcrossGateways`, alias-audit.js's
153
+ * `findFabricatedAliasRepairs`) -- to that twin. Every other stale/drifted
154
+ * alias (typo, retired model, user-invented id) is left for `amicus
155
+ * models --check` instead, never guessed at.
156
+ */
157
+ repairFabricatedAlias:
158
+ 'amicus doctor --fix (rewrites a fabricated bare alias to its catalog-confirmed OpenRouter id — safe only for the narrow class doctor can prove; use `amicus models --check` for anything else)',
159
+
160
+ /**
161
+ * A3 (council review of PR 198): same repairable class as
162
+ * `repairFabricatedAlias` above, but the cached catalog `evaluateAliasesCheck`
163
+ * would repair FROM is itself stale (older than doctor's own `catalog`
164
+ * check's freshness window) -- a stale catalog can be missing rows that
165
+ * would make a "fabricated" id look repairable when it is merely unfetched,
166
+ * so the repair declines rather than write on unverified evidence.
167
+ */
168
+ repairFabricatedAliasStaleCatalog:
169
+ 'amicus models --refresh, then amicus doctor --fix (the alias looks fabricated but the cached catalog is stale — refresh it first so the repair rests on current data, not a possibly-incomplete snapshot)',
146
170
  });
147
171
 
148
172
  module.exports = REMEDIATION_HINTS;
@@ -124,13 +124,13 @@ function costPanel(run, tally) {
124
124
 
125
125
  // ⚠️ PRE-FLIGHT (P3): F04's correction is implemented here rather than left as prose.
126
126
  // VERIFIED on shipped main (Task 0): `finalize(exitCode, error)` writes `error: error || null`
127
- // (src/council/run.js:98-100), and `return finalize(degraded.value ? 2 : 0)` (:293) is the ONLY
127
+ // (run-finalize.js :: writeRunTerminal), and `return finalize(degraded.value ? 2 : 0)` (run.js:279) is the ONLY
128
128
  // exit-2 path — it passes NO error. Every error-bearing call is `finalize(1, …)`. So on a
129
129
  // `status:'partial'` run — precisely the run this panel exists to explain — `run.error` is
130
130
  // GUARANTEED null, and the old one-line formula rendered "undefined: undefined".
131
131
  // Name the stage instead. Stage status is a closed set (DE-ROT F19): running / complete /
132
- // skipped / error, and `run-chair.js:114` writes 'error' for a chair that failed after retry +
133
- // fallback promotion, 'skipped' (:89) for one the cost ceiling skipped.
132
+ // skipped / error, and `run-chair.js :: chairStatus` writes 'error' for a chair that failed after retry +
133
+ // fallback promotion, 'skipped' (run-chair.js :: skippedForCost) for one the cost ceiling skipped.
134
134
  function degradedReason(run) {
135
135
  // exit-1 path: the engine wrote a structured {code, message}.
136
136
  if (run.error && run.error.code) { return `${run.error.code}: ${run.error.message}`; }