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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +106 -0
- package/README.md +3 -3
- package/docs/CITATIONS.md +13 -5
- package/docs/configuration.md +1 -1
- package/docs/usage.md +1 -1
- package/electron/ipc-setup.js +18 -2
- package/electron/main.js +46 -3
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +231 -29
- package/electron/workspace-ui/live-seats.js +4 -4
- package/package.json +2 -1
- package/src/cli-handlers-doctor.js +11 -14
- package/src/council/parse-stage2.js +1 -1
- package/src/sidecar/setup.js +124 -0
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/curated-models.js +5 -5
- package/src/utils/doctor-alias-check.js +152 -0
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +24 -0
- package/src/workspace/run-detail.js +3 -3
|
@@ -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
|
|
53
|
-
*
|
|
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
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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:
|
|
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
|
|
144
|
-
* exactly. `resolveTier`
|
|
145
|
-
* the tier
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* `
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
|
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
|
-
|
|
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`
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* `
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
|
231
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -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
|
|
83
|
-
* `
|
|
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
|
-
// (
|
|
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
|
|
133
|
-
// fallback promotion, '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}`; }
|