amicus 4.7.1 → 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 +988 -1
- package/README.md +5 -4
- package/docs/CITATIONS.md +122 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +179 -32
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/troubleshooting.md +3 -3
- package/docs/usage.md +2 -2
- package/electron/ipc-setup.js +18 -2
- package/electron/main.js +46 -3
- package/electron/setup-ui-aliases.js +2 -2
- 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/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +4 -1
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/src/cli-handlers-doctor.js +18 -14
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/parse-stage2.js +1 -1
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +45 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +166 -161
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +20 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +1 -1
- package/src/sidecar/setup.js +137 -2
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- 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/no-output-backstop.js +1 -1
- 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 +37 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +30 -4
- package/src/workspace/seat-space.js +143 -0
package/src/sidecar/setup.js
CHANGED
|
@@ -51,7 +51,11 @@ function createDefaultConfig(defaultModel) {
|
|
|
51
51
|
const cfg = {
|
|
52
52
|
...existing,
|
|
53
53
|
default: existing.default || defaultModel,
|
|
54
|
-
|
|
54
|
+
// Same restatement as the readline gate below (fix round 3, G-2): a spread
|
|
55
|
+
// into `{}` re-materialises Object.prototype. `saveConfig` rebuilds this
|
|
56
|
+
// into its own literal anyway, so this one is defense in depth rather than
|
|
57
|
+
// a measured hole — recorded as such rather than claimed as a fix.
|
|
58
|
+
aliases: { __proto__: null, ...getDefaultAliases(), ...(existing.aliases || {}) },
|
|
55
59
|
};
|
|
56
60
|
saveConfig(cfg);
|
|
57
61
|
logger.info('Default config ensured', {
|
|
@@ -128,7 +132,14 @@ function resolveChoice(input, picks, catalog) {
|
|
|
128
132
|
return { modelId: input };
|
|
129
133
|
}
|
|
130
134
|
const cfg = loadConfig();
|
|
131
|
-
|
|
135
|
+
// ⚠️ `__proto__: null` — v4.8 SI-22.4 fix round 3 (council G-2). `input` here
|
|
136
|
+
// is FREE-FORM readline text, and the gate below is `aliases[input] !==
|
|
137
|
+
// undefined`, so on a plain literal `toString` / `valueOf` / `constructor` /
|
|
138
|
+
// `hasOwnProperty` all measured TRUE and returned `{alias: input, noUpgrade:
|
|
139
|
+
// true}` — setup accepted them as existing aliases. Spreading a
|
|
140
|
+
// null-prototype object into a bare `{}` produces a PLAIN object again, so the
|
|
141
|
+
// curated-models fix does not reach this literal; the seed has to be restated.
|
|
142
|
+
const aliases = { __proto__: null, ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
|
|
132
143
|
if (aliases[input] !== undefined) {
|
|
133
144
|
return { alias: input, noUpgrade: true };
|
|
134
145
|
}
|
|
@@ -342,6 +353,73 @@ async function printDoctorFinale(deps = {}) {
|
|
|
342
353
|
}
|
|
343
354
|
}
|
|
344
355
|
|
|
356
|
+
/**
|
|
357
|
+
* #138 second level: after a family pick, let the user name a SPECIFIC model
|
|
358
|
+
* from that vendor. Returns the chosen catalog id, or null to keep the
|
|
359
|
+
* family default (bare Enter, an empty shortlist, or two invalid entries).
|
|
360
|
+
*
|
|
361
|
+
* The prompt deliberately avoids the substring "Pick a number":
|
|
362
|
+
* tests/sidecar/setup.test.js:392,475 branch on that literal and would
|
|
363
|
+
* answer '' here, leaving new coverage green but vacuous.
|
|
364
|
+
* @param {(q: string) => Promise<string>} ask
|
|
365
|
+
* @param {(line: string) => void} print
|
|
366
|
+
* @param {{suggested: Array<object>, rest: Array<object>, total: number}} shortlist
|
|
367
|
+
* @param {string} vendorPath
|
|
368
|
+
* @returns {Promise<string|null>}
|
|
369
|
+
*/
|
|
370
|
+
async function promptForVendorModel(ask, print, shortlist, vendorPath) {
|
|
371
|
+
if (!shortlist || shortlist.total === 0) { return null; }
|
|
372
|
+
|
|
373
|
+
let visible = shortlist.suggested;
|
|
374
|
+
const fmt = (r, i) => {
|
|
375
|
+
const price = r.pricePerMInput === null ? 'n/a' : `$${r.pricePerMInput.toFixed(2)}/M in`;
|
|
376
|
+
const ctx = r.contextLength === null || r.contextLength === undefined ? '' : ` · ctx ${r.contextLength}`;
|
|
377
|
+
return ` ${i + 1}) ${r.id}${ctx} · ${price}${r.isRecommended ? ' (recommended)' : ''}`;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const render = () => {
|
|
381
|
+
print('');
|
|
382
|
+
print(`Which ${vendorPath} model?`);
|
|
383
|
+
visible.forEach((r, i) => print(fmt(r, i)));
|
|
384
|
+
if (visible.length < shortlist.total) {
|
|
385
|
+
print(` … ${shortlist.total - visible.length} more`);
|
|
386
|
+
}
|
|
387
|
+
print('');
|
|
388
|
+
};
|
|
389
|
+
render();
|
|
390
|
+
|
|
391
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
392
|
+
const hint = visible.length < shortlist.total ? ", 'a' for all" : '';
|
|
393
|
+
const answer = (await ask(
|
|
394
|
+
`Choose 1-${visible.length}${hint}, a full model id, or Enter to keep the default: `
|
|
395
|
+
) || '').trim();
|
|
396
|
+
|
|
397
|
+
if (answer === '') { return null; }
|
|
398
|
+
if (answer.toLowerCase() === 'a' && visible.length < shortlist.total) {
|
|
399
|
+
visible = shortlist.suggested.concat(shortlist.rest);
|
|
400
|
+
render();
|
|
401
|
+
attempt--; // expanding the list is not a failed attempt
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (/^\d+$/.test(answer)) {
|
|
405
|
+
const n = Number.parseInt(answer, 10);
|
|
406
|
+
if (n >= 1 && n <= visible.length) { return visible[n - 1].id; }
|
|
407
|
+
}
|
|
408
|
+
if (answer.includes('/')) { return answer; }
|
|
409
|
+
// F1 (council review, PR 196): the final attempt used to fall through
|
|
410
|
+
// silently -- the loop just exited and the caller kept the family
|
|
411
|
+
// default with no feedback at all, so the user's last keystroke
|
|
412
|
+
// visibly did nothing. Both attempts now print, but the last one also
|
|
413
|
+
// states the consequence instead of implying a further retry.
|
|
414
|
+
if (attempt === 0) {
|
|
415
|
+
print(`Invalid choice: "${answer}".`);
|
|
416
|
+
} else {
|
|
417
|
+
print(`Invalid choice: "${answer}". Keeping the family default.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
345
423
|
/**
|
|
346
424
|
* Run the readline-based setup wizard (headless fallback)
|
|
347
425
|
*
|
|
@@ -475,6 +553,62 @@ async function runReadlineSetup() {
|
|
|
475
553
|
const fallback = getDefaultAliases()[chosen.alias];
|
|
476
554
|
if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
|
|
477
555
|
}
|
|
556
|
+
|
|
557
|
+
// #138: offer the family -> model second level. `pick.vendorPath` is
|
|
558
|
+
// the vendor whose catalog rows we drill into; a chosen id REPLACES
|
|
559
|
+
// the flagship route for this alias only. Guarded — a picker failure
|
|
560
|
+
// must never abort a setup run that has already collected keys.
|
|
561
|
+
//
|
|
562
|
+
// R4a (fix round 2, supersedes R4's noUpgrade disjunct; wording
|
|
563
|
+
// corrected in the F7 fix wave -- the previous wording claimed the
|
|
564
|
+
// governing rule was "never ask about the same vendor twice in one
|
|
565
|
+
// run", which this guard cannot implement and does not):
|
|
566
|
+
//
|
|
567
|
+
// `vendorAliasesWritten` holds PROVIDER names (runProviderDefaultPickers
|
|
568
|
+
// adds each entry of `foundKeys`, e.g. 'google'/'openai'/'anthropic'/
|
|
569
|
+
// 'deepseek'), while `chosen.alias` is a FAMILY ALIAS name ('gemini',
|
|
570
|
+
// 'gemini-pro', 'gpt', 'opus', 'deepseek'). The two prompts write
|
|
571
|
+
// DIFFERENT config keys -- the per-provider picker writes
|
|
572
|
+
// `config.aliases[provider]` (e.g. `aliases.google`), this drill-down
|
|
573
|
+
// writes `config.aliases[chosen.alias]` (e.g. `aliases.gemini`) -- so
|
|
574
|
+
// `!vendorAliasesWritten.has(chosen.alias)` only skips the drill-down
|
|
575
|
+
// when the alias STRING happens to collide with an already-written
|
|
576
|
+
// provider name. Today that's 'deepseek' alone (alias 'deepseek' ===
|
|
577
|
+
// provider 'deepseek'); every other family alias never collides, so
|
|
578
|
+
// this drill-down still fires for those even after the per-provider
|
|
579
|
+
// phase ran for that family's vendor -- correctly: the two prompts
|
|
580
|
+
// bind different keys, they are not "the same vendor twice".
|
|
581
|
+
//
|
|
582
|
+
// TRAP: re-keying this guard to `pick.vendorPath` (so it tests the
|
|
583
|
+
// actual vendor instead of the alias-name coincidence) looks like the
|
|
584
|
+
// obvious fix for the mismatch above and is NOT one -- it was
|
|
585
|
+
// measured to delete issue 138's feature entirely for every user
|
|
586
|
+
// holding a direct key, by skipping the drill-down for every family
|
|
587
|
+
// whose per-provider picker already ran this session. Leave this
|
|
588
|
+
// guard exactly as it is.
|
|
589
|
+
//
|
|
590
|
+
// `chosen.noUpgrade` does NOT imply no question was asked for this
|
|
591
|
+
// vendor: a user can type a known alias name (noUpgrade=true) for a
|
|
592
|
+
// vendor the per-provider phase already walked through this run, and
|
|
593
|
+
// that must still be skipped. Whenever noUpgrade is true AND the
|
|
594
|
+
// alias is genuinely unasked-about, `!vendorAliasesWritten.has(...)`
|
|
595
|
+
// is already true on its own, so dropping the noUpgrade disjunct
|
|
596
|
+
// loses no legitimate firing case -- only the double-ask.
|
|
597
|
+
if (pick && !vendorAliasesWritten.has(chosen.alias)) {
|
|
598
|
+
try {
|
|
599
|
+
const { buildModelShortlist } = require('../utils/model-shortlist');
|
|
600
|
+
const shortlist = buildModelShortlist(pick.vendorPath, {
|
|
601
|
+
catalog,
|
|
602
|
+
recommendedId: cfg.aliases[chosen.alias],
|
|
603
|
+
});
|
|
604
|
+
const specific = await promptForVendorModel(
|
|
605
|
+
askQuestion.bind(null, rl), console.log, shortlist, pick.vendorPath
|
|
606
|
+
);
|
|
607
|
+
if (specific) { cfg.aliases[chosen.alias] = specific; }
|
|
608
|
+
} catch (err) {
|
|
609
|
+
console.log(`Note: couldn't list ${pick.vendorPath} models (${err.message}).`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
478
612
|
} else {
|
|
479
613
|
cfg.default = chosen.modelId;
|
|
480
614
|
}
|
|
@@ -602,6 +736,7 @@ module.exports = {
|
|
|
602
736
|
createDefaultConfig,
|
|
603
737
|
deriveFreeAlias,
|
|
604
738
|
detectApiKeys,
|
|
739
|
+
promptForVendorModel,
|
|
605
740
|
runFreeCouncilBranch,
|
|
606
741
|
runInteractiveSetup,
|
|
607
742
|
runReadlineSetup,
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Alias Audit (F5) — report + suggest
|
|
2
|
+
* Alias Audit (F5) — report + suggest for most classes; doctor --fix auto-repairs one narrow, mechanically-unambiguous class (B3).
|
|
3
3
|
*
|
|
4
4
|
* Finds aliases/routes pointing at models absent from the catalog and
|
|
5
5
|
* suggests current same-vendor replacements. Pure functions over inputs;
|
|
6
6
|
* collectAliasSources() does the gathering. Consumed by `amicus models
|
|
7
7
|
* --check` and the npm wrapper scripts.
|
|
8
|
+
*
|
|
9
|
+
* `findFabricatedAliasRepairs()` (B3, council review of PR 198) is the one
|
|
10
|
+
* exception to "never auto-repair": it detects the single class `doctor
|
|
11
|
+
* --fix` can safely rewrite unattended -- a bare id `classifyModel` proves
|
|
12
|
+
* `invalid` on the `direct` gateway AND for which the catalog holds an
|
|
13
|
+
* unambiguous OpenRouter twin (`pairAcrossGateways`, never string
|
|
14
|
+
* concatenation) -- still pure detection here; the actual write lives in
|
|
15
|
+
* `doctor-alias-check.js`'s `repairAlias()`. Every OTHER class this module
|
|
16
|
+
* finds (typo, retired model, ambiguous twin, drifted-but-live) stays
|
|
17
|
+
* report-and-suggest only, same as before -- this module does not become a
|
|
18
|
+
* general auto-repair tool.
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
'use strict';
|
|
@@ -132,7 +143,10 @@ function suggestReplacements(staleModel, catalog, n = 3) {
|
|
|
132
143
|
* fresh `amicus setup` would seed today — the v4.6.1 release-gate class
|
|
133
144
|
* (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
|
|
134
145
|
* findStaleAliases passes it, no longer what the family resolves to).
|
|
135
|
-
* Report + suggest
|
|
146
|
+
* Report + suggest only -- unlike `findFabricatedAliasRepairs` below (B3's
|
|
147
|
+
* narrow, mechanically-unambiguous exception), THIS function never
|
|
148
|
+
* auto-repairs: a drifted-but-live target has no single unambiguous
|
|
149
|
+
* "correct" answer to converge on the way a fabricated id does.
|
|
136
150
|
*
|
|
137
151
|
* Only user-config rows are checked (defaults/curated follow the catalog by
|
|
138
152
|
* construction), only for aliases that are quick-pick families (a custom
|
|
@@ -178,4 +192,68 @@ function findDriftedStoredAliases(sources, catalog) {
|
|
|
178
192
|
.map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
|
|
179
193
|
}
|
|
180
194
|
|
|
181
|
-
|
|
195
|
+
/**
|
|
196
|
+
* B3 (council review of PR 198, issue 195): the narrow, mechanically-
|
|
197
|
+
* unambiguous class of stored alias `doctor --fix` may repair -- a bare
|
|
198
|
+
* `<vendor>/<model>` id in a 'user-config' row (what's actually persisted to
|
|
199
|
+
* `config.aliases`, the only source a repair can rewrite) that
|
|
200
|
+
* `classifyModel` proves `invalid` on the `direct` gateway AND for which the
|
|
201
|
+
* catalog contains an unambiguous OpenRouter twin (`pairAcrossGateways` --
|
|
202
|
+
* never string-concatenation). This is exactly the class the pre-fix
|
|
203
|
+
* v4.8.0 `chooseRowId`/`applyProviderDefault` could persist (an unconditional
|
|
204
|
+
* `openrouter/` strip with no catalog evidence); PR 198's
|
|
205
|
+
* `directFormIfProven` (model-canonicalization.js) now refuses to write it
|
|
206
|
+
* going forward, so this function finds the ones already ON DISK and
|
|
207
|
+
* converges them onto the same OpenRouter-prefixed answer the picker would
|
|
208
|
+
* offer today, rather than inventing a third one.
|
|
209
|
+
*
|
|
210
|
+
* Deliberately narrower than findStaleAliases: a typo'd, retired, or
|
|
211
|
+
* user-invented id also classifies `invalid`, but `pairAcrossGateways` can
|
|
212
|
+
* only find a twin when the extracted vendor + version token names a REAL
|
|
213
|
+
* model that still exists, under that exact normalized name, in the
|
|
214
|
+
* catalog's OpenRouter namespace -- a typo or a genuinely dead model can
|
|
215
|
+
* never satisfy that. Ambiguous (>1 match) or absent twins are left
|
|
216
|
+
* untouched -- still reported as stale by findStaleAliases, never repaired.
|
|
217
|
+
*
|
|
218
|
+
* DIVERGENT_VENDORS (e.g. anthropic) are excluded on purpose:
|
|
219
|
+
* model-canonicalization.js gates that set FIRST and unconditionally, so its
|
|
220
|
+
* predicates never strip a divergent vendor's prefix in the first place --
|
|
221
|
+
* a bare divergent-vendor alias could not have been produced by the bug
|
|
222
|
+
* this repairs, so treating one as fabricated here would be inventing a new
|
|
223
|
+
* class, not converging on the picker's own answer.
|
|
224
|
+
*
|
|
225
|
+
* Empty/absent catalog -> [] : classifyModel can only return 'unknown' with
|
|
226
|
+
* no catalog rows, and 'unknown' never authorises a repair -- no positive
|
|
227
|
+
* evidence, no write.
|
|
228
|
+
* @param {Array<{alias:string,model:string,source:string}>} sources
|
|
229
|
+
* @param {Array<{id:string}>} catalog
|
|
230
|
+
* @returns {Array<{alias:string,oldId:string,newId:string}>}
|
|
231
|
+
*/
|
|
232
|
+
function findFabricatedAliasRepairs(sources, catalog) {
|
|
233
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
234
|
+
const { classifyModel } = require('./model-classification');
|
|
235
|
+
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
236
|
+
const { DIVERGENT_VENDORS } = require('./curated-models');
|
|
237
|
+
const catalogInfo = { models: catalog };
|
|
238
|
+
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const { alias, model, source } of sources) {
|
|
241
|
+
if (source !== 'user-config') { continue; }
|
|
242
|
+
if (typeof model !== 'string' || model.startsWith('openrouter/')) { continue; } // not bare
|
|
243
|
+
const parts = model.split('/');
|
|
244
|
+
if (parts.length < 2) { continue; } // not a <vendor>/<model> shape
|
|
245
|
+
const vendor = parts[0];
|
|
246
|
+
if (DIVERGENT_VENDORS.has(vendor)) { continue; }
|
|
247
|
+
const versionToken = parts.slice(1).join('/');
|
|
248
|
+
if (classifyModel(model, 'direct', catalogInfo) !== 'invalid') { continue; }
|
|
249
|
+
const paired = pairAcrossGateways(vendor, versionToken, catalogInfo);
|
|
250
|
+
if (!paired.openrouter) { continue; } // ambiguous or absent twin -- leave as a warning
|
|
251
|
+
out.push({ alias, oldId: model, newId: paired.openrouter });
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements,
|
|
258
|
+
findFabricatedAliasRepairs,
|
|
259
|
+
};
|
package/src/utils/config.js
CHANGED
|
@@ -60,7 +60,16 @@ function saveConfig(configData) {
|
|
|
60
60
|
if (configData && configData.aliases) {
|
|
61
61
|
const cleaned = {};
|
|
62
62
|
for (const [key, value] of Object.entries(configData.aliases)) {
|
|
63
|
-
|
|
63
|
+
// `key === '__proto__'` — v4.8 SI-22.4 fix round 3 (council G-5). Without
|
|
64
|
+
// it the write below (`cleaned[key] = value`) hit Object.prototype's
|
|
65
|
+
// INHERITED `__proto__` setter, which ignores a string, so the alias
|
|
66
|
+
// vanished with NO "Removing invalid alias" notice — the only silent
|
|
67
|
+
// removal in this loop. Rejecting it explicitly puts it on the same
|
|
68
|
+
// footing as the `'null'` key beside it: same branch, same message.
|
|
69
|
+
// (No pollution was possible either way — only strings reach this line,
|
|
70
|
+
// and the setter ignores them — so this is an announcement fix, not a
|
|
71
|
+
// security one. Stated that way on purpose.)
|
|
72
|
+
if (key === 'null' || key === '__proto__' || !value || typeof value !== 'string' || value === 'null') {
|
|
64
73
|
process.stderr.write(
|
|
65
74
|
`Notice: Removing invalid alias '${key}' (value: ${JSON.stringify(value)}) from config.\n`
|
|
66
75
|
);
|
|
@@ -78,7 +87,14 @@ function saveConfig(configData) {
|
|
|
78
87
|
|
|
79
88
|
/** @returns {object} Copy of the default alias map */
|
|
80
89
|
function getDefaultAliases() {
|
|
81
|
-
|
|
90
|
+
// ⚠️ `__proto__: null` must be RESTATED here, not inherited. A spread into a
|
|
91
|
+
// bare `{}` literal produces a plain object again, so fixing the builders in
|
|
92
|
+
// curated-models.js does NOT reach this copy — measured, not assumed. Its
|
|
93
|
+
// consumers index it: `sidecar/setup.js:479` (`getDefaultAliases()[alias]`)
|
|
94
|
+
// and `electron/setup-ui-aliases.js :: buildAliasEditorHTML`
|
|
95
|
+
// (`aliases[key] !== undefined`).
|
|
96
|
+
// Named mutant "BUILDERPROTO" covers this line too.
|
|
97
|
+
return { __proto__: null, ...DEFAULT_ALIASES };
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/**
|
|
@@ -216,7 +232,44 @@ function checkConfigChanged(currentHash) {
|
|
|
216
232
|
function getEffectiveAliases() {
|
|
217
233
|
const config = loadConfig();
|
|
218
234
|
const userAliases = (config && config.aliases) || {};
|
|
219
|
-
|
|
235
|
+
// `__proto__: null` — v4.8 SI-22.4 fix round 2 (council B1). This table is
|
|
236
|
+
// read with BARE INDEXING by five gates, so on a normal object a member
|
|
237
|
+
// literally named 'toString' / 'constructor' / 'valueOf' / 'hasOwnProperty'
|
|
238
|
+
// resolved off Object.prototype to a truthy Function and was treated as a
|
|
239
|
+
// KNOWN ALIAS. Measured, not argued:
|
|
240
|
+
// resolveModel('toString') -> the Function itself,
|
|
241
|
+
// typeof 'function', where every caller expects a model-id STRING
|
|
242
|
+
// (`:111` and `:142` gate on `!== undefined`, which a Function passes)
|
|
243
|
+
// classifyCouncilMembers(['toString '], []) -> ACCEPTED, i.e. runnable
|
|
244
|
+
// The SAME defect class this release already closed at other lookup tables —
|
|
245
|
+
// `tally.js :: VERDICTS`, `report.js :: SYMBOL`, `debate.js :: PAST_TENSE`
|
|
246
|
+
// (all `__proto__: null`), plus `street-cred.js :: perJudgeRank` and
|
|
247
|
+
// `report.js :: ROLE_SUFFIX` (both `Object.create(null)`, the same guarantee
|
|
248
|
+
// in the other spelling). The ALIAS table was not among them.
|
|
249
|
+
// ⚠️ SI-22.4 WIDENED it and that is why it is fixed here: at BASE the padded
|
|
250
|
+
// spelling ('toString ') missed the prototype and was correctly dropped;
|
|
251
|
+
// trimming before the lookup landed it on the inherited property. The
|
|
252
|
+
// unpadded spelling was already accepted, so restoring only the padded case
|
|
253
|
+
// would take more code AND deliberately preserve a known hole.
|
|
254
|
+
// ⚠️ Fixed HERE, not at the call sites — one line closes all five, each
|
|
255
|
+
// MEASURED at its own gate expression with `'toString'` (not inferred from
|
|
256
|
+
// this one): `resolveModel` `:111`/`:142` (`!== undefined` true→false) ·
|
|
257
|
+
// `classifyCouncilMembers` (accepted→dropped, end to end) ·
|
|
258
|
+
// `council/presets-cli.js:41` (`amicus council save`: unresolved false→true) ·
|
|
259
|
+
// `pack/pack-validate.js:71` (`seatOk` true→false) ·
|
|
260
|
+
// `utils/route-launch.js:205` (`isAlias` true→false).
|
|
261
|
+
// No consumer breaks: every reference either indexes (`aliases[key]`) or
|
|
262
|
+
// iterates own-enumerable keys (`Object.entries`/`Object.keys` —
|
|
263
|
+
// `buildProviderModels`, `formatAliasNames`, `mcp-tools.js :: getGuideText`,
|
|
264
|
+
// `sidecar/models.js :: aliasMarks`), and both behave identically on a
|
|
265
|
+
// null-prototype object. NOTHING calls a method ON the object — swept
|
|
266
|
+
// uncapped over `src/` and `electron/` for `aliases.<x>`, `in aliases`,
|
|
267
|
+
// `Object.values`, `JSON.stringify`, spread and `for…in`.
|
|
268
|
+
// A `__proto__` key inside the user's own config.json is copied as an ORDINARY
|
|
269
|
+
// own property by spread (never the setter), so the prototype stays null —
|
|
270
|
+
// measured on both a literal and a `JSON.parse`d source.
|
|
271
|
+
// Named mutant "PROTOALIASES": drop `__proto__: null` from the literal below.
|
|
272
|
+
return { __proto__: null, ...DEFAULT_ALIASES, ...userAliases };
|
|
220
273
|
}
|
|
221
274
|
|
|
222
275
|
/**
|
|
@@ -414,11 +467,42 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
414
467
|
* at the last refresh; the leg itself fails pre-flight with the actionable
|
|
415
468
|
* local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
|
|
416
469
|
* catalog that omits the resolved id is a definitive drop.
|
|
417
|
-
*
|
|
470
|
+
*
|
|
471
|
+
* WHITESPACE (v4.8 SI-22.4). Each member is TRIMMED before it is classified,
|
|
472
|
+
* closing a divergence: `--models` already trimmed
|
|
473
|
+
* (`sidecar/fanout-validate.js :: parseModelsList`, and `cli-council-run-bench.js
|
|
474
|
+
* :: parseList` on the council surface) while `--council` did not, so the same
|
|
475
|
+
* stray space was benign on one flag and, here, converted a typo into a dropped
|
|
476
|
+
* member and a degraded (2) exit. ⚠️ The dominant effect is RESURRECTION, not
|
|
477
|
+
* de-duplication: a padded member that is dropped today starts RUNNING, which
|
|
478
|
+
* is a new paid leg. Where the trim makes two members collide, the bench
|
|
479
|
+
* becomes a real twin and `seats.js :: buildSeats` mints `alias#N` for both.
|
|
480
|
+
* An all-whitespace member trims to `''`, which no alias table names, so gate 1
|
|
481
|
+
* below drops it — the `.filter(Boolean)` half of `parseModelsList`'s shape,
|
|
482
|
+
* reached without a third `reason` string (see the tripwire note below).
|
|
483
|
+
* @param {string[]} members council members as configured — aliases or
|
|
484
|
+
* provider/model ids, trimmed per member here. ⚠️ This is the only place the
|
|
485
|
+
* preset READ path trims — NOT the only place the preset path trims at all,
|
|
486
|
+
* and NOT the only trim a member meets. The WRITE side already trimmed:
|
|
487
|
+
* `council/presets-cli.js:34` (`amicus council save`) stores
|
|
488
|
+
* `.split(',').map(m => m.trim()).filter(Boolean)`, and the only other writer
|
|
489
|
+
* of `cfg.councils` in `src/` (`sidecar/setup.js:593`, the seeded `free`
|
|
490
|
+
* council) composes its members from generated/existing alias KEYS, which
|
|
491
|
+
* cannot carry user padding. So a padded member in `cfg.councils` comes from
|
|
492
|
+
* a hand-edited `config.json`, and that is the case this trim serves.
|
|
493
|
+
* Downstream, both council
|
|
494
|
+
* surfaces re-join the expanded bench and re-parse it downstream —
|
|
495
|
+
* `cli-handlers-fanout.js:91` → `:119` → `sidecar/fanout.js ::
|
|
496
|
+
* validateFanoutModels` → `parseModelsList`, and `mcp-council-run.js:177` →
|
|
497
|
+
* the spawned child's `cli-council-run-bench.js :: parseList` — so a member
|
|
498
|
+
* that somehow kept padding past this point would still be trimmed there
|
|
418
499
|
* @param {Array<{id:string}>} [catalog]
|
|
419
500
|
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
|
|
420
501
|
* `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
|
|
421
502
|
* callers keep working); `droppedMembers` additively pairs each with WHY.
|
|
503
|
+
* ⚠️ Both report the member RAW — untrimmed, byte-for-byte as configured
|
|
504
|
+
* (v4.8 SI-22.4, R22.4-2) — so a user can find the offending string in their
|
|
505
|
+
* own config. Only `models` carries the trimmed value.
|
|
422
506
|
*
|
|
423
507
|
* Standing note (D18, v4.7 PR5): each `droppedMembers` entry is `{member, reason}`
|
|
424
508
|
* (that is the real key — BACKLOG.md's description of this shape had drifted to
|
|
@@ -442,18 +526,29 @@ function classifyCouncilMembers(members, catalog = []) {
|
|
|
442
526
|
const models = [];
|
|
443
527
|
const dropped = [];
|
|
444
528
|
const droppedMembers = [];
|
|
445
|
-
for (const
|
|
529
|
+
for (const raw of members) {
|
|
530
|
+
// v4.8 SI-22.4. Trim BEFORE gate 1 below, never after: a padded ALIAS
|
|
531
|
+
// ('gpt ') must reach the alias table as written in the table, and a padded
|
|
532
|
+
// full id ('openai/gpt-5 ') must reach the catalog lookup clean. Trimming
|
|
533
|
+
// downstream of either gate would leave both misses in place. Non-strings
|
|
534
|
+
// pass through untouched so their `.includes` still throws exactly as it
|
|
535
|
+
// did before this line existed. Named mutant "NOTRIM": drop the `.trim()`.
|
|
536
|
+
const member = typeof raw === 'string' ? raw.trim() : raw;
|
|
446
537
|
const id = member.includes('/') ? member : aliases[member];
|
|
538
|
+
// R22.4-2: `models` gets the TRIMMED value, `dropped`/`droppedMembers` get
|
|
539
|
+
// `raw` — a member still dropped after trimming is reported as the user
|
|
540
|
+
// wrote it, or they cannot grep their own config for it. Named mutant
|
|
541
|
+
// "TRIMDROPPED": report `member` instead of `raw` in the two drop branches.
|
|
447
542
|
if (!id) { // alias no longer resolves
|
|
448
|
-
dropped.push(
|
|
449
|
-
droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
|
|
543
|
+
dropped.push(raw);
|
|
544
|
+
droppedMembers.push({ member: raw, reason: 'alias no longer resolves to a known model' });
|
|
450
545
|
continue;
|
|
451
546
|
}
|
|
452
547
|
const vendor = typeof id === 'string' ? id.split('/')[0] : '';
|
|
453
548
|
if (isLocalProvider(vendor)) { models.push(member); continue; }
|
|
454
549
|
if (known.size > 0 && !known.has(id)) { // delisted model
|
|
455
|
-
dropped.push(
|
|
456
|
-
droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
|
|
550
|
+
dropped.push(raw);
|
|
551
|
+
droppedMembers.push({ member: raw, reason: 'resolved id is not present in the cached model catalog' });
|
|
457
552
|
continue;
|
|
458
553
|
}
|
|
459
554
|
models.push(member);
|
|
@@ -465,8 +560,11 @@ function classifyCouncilMembers(members, catalog = []) {
|
|
|
465
560
|
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
466
561
|
* Unresolvable aliases and delisted ids are dropped with a warning rather than
|
|
467
562
|
* fail-fast-aborting the whole wave (classification: classifyCouncilMembers
|
|
468
|
-
* above). Returns members
|
|
469
|
-
* them again.
|
|
563
|
+
* above). Returns members UNRESOLVED (the alias or id as configured, never the
|
|
564
|
+
* id an alias maps to) — leg-time validation resolves them again. ⚠️ Not
|
|
565
|
+
* byte-identical to the configured string since v4.8 SI-22.4: classification
|
|
566
|
+
* trims each member, so `models[i]` is the configured member minus any
|
|
567
|
+
* surrounding whitespace. `dropped`/`droppedMembers` still carry it raw.
|
|
470
568
|
*
|
|
471
569
|
* Resolution order: user config (`config.councils`) is checked first; when
|
|
472
570
|
* `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
|
|
@@ -111,6 +111,13 @@ const CARDLESS = [
|
|
|
111
111
|
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
|
112
112
|
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
|
|
113
113
|
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
|
|
114
|
+
// inkling added 2026-08-14: the council-review workflow's default bench
|
|
115
|
+
// names it, and this table is the FLOOR a runner falls back to when no
|
|
116
|
+
// alias map is provisioned (workflow_call callers, forks) — there, a
|
|
117
|
+
// locally defined alias still resolves to nothing. Pinned to the full
|
|
118
|
+
// model, not `inkling-small`: the bench seat wants the flagship's
|
|
119
|
+
// judgment. `:batch` is not pinned (wrong for an interactive council leg).
|
|
120
|
+
{ alias: 'inkling', routes: { openrouter: 'openrouter/thinkingmachines/inkling' } },
|
|
114
121
|
];
|
|
115
122
|
|
|
116
123
|
/**
|
|
@@ -242,7 +249,14 @@ function directFormProvenance() {
|
|
|
242
249
|
* without corrupting divergent-vendor ids (e.g. Anthropic's dash format).
|
|
243
250
|
*/
|
|
244
251
|
function toGatewayRoutes() {
|
|
245
|
-
|
|
252
|
+
// `__proto__: null` — v4.8 SI-22.4 round 3 (G-1). Read by BARE INDEXING
|
|
253
|
+
// downstream, so a plain `{}` let an alias named 'toString'/'constructor'/
|
|
254
|
+
// 'valueOf'/'hasOwnProperty' resolve off Object.prototype to a truthy
|
|
255
|
+
// Function — including on the auto-repair path (`alias-resolver.js ::
|
|
256
|
+
// autoRepairAlias`), which `getEffectiveAliases`'s own fix could never reach.
|
|
257
|
+
// Full measurement + why THREE seeds were needed:
|
|
258
|
+
// tests/council/preset-trim-mutants.js :: BUILDERPROTO (the named mutant).
|
|
259
|
+
const out = { __proto__: null };
|
|
246
260
|
for (const f of FAMILIES) { out[f.alias] = gatewayRoutesFor(f.vendorPath, f.fallback); }
|
|
247
261
|
for (const e of CARDLESS) { out[e.alias] = gatewayRoutesFor(vendorOf(e.routes.openrouter), e.routes); }
|
|
248
262
|
return out;
|
|
@@ -262,7 +276,8 @@ function toGatewayRoutes() {
|
|
|
262
276
|
* shipped defaults.
|
|
263
277
|
*/
|
|
264
278
|
function toDefaultAliases() {
|
|
265
|
-
|
|
279
|
+
// `__proto__: null` — see toGatewayRoutes above. Becomes DEFAULT_ALIASES.
|
|
280
|
+
const out = { __proto__: null };
|
|
266
281
|
for (const [alias, routes] of Object.entries(toGatewayRoutes())) {
|
|
267
282
|
out[alias] = routes.direct || routes.openrouter;
|
|
268
283
|
}
|
package/src/utils/degrade.js
CHANGED
|
@@ -17,6 +17,11 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
17
17
|
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
18
|
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
19
|
'stage1-retry',
|
|
20
|
+
// v4.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
|
|
21
|
+
// returned legs but none its own; a returned leg matching no roster slot; and (T5.5, `-rv` only)
|
|
22
|
+
// a leg that DID match a slot but whose join key names no judge the wave launched.
|
|
23
|
+
// Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
|
|
24
|
+
'seat-unbound',
|
|
20
25
|
'internal',
|
|
21
26
|
// doctor channels
|
|
22
27
|
'doctor-check-failed', 'doctor-fix',
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/utils/doctor-alias-check.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module utils/doctor-alias-check
|
|
6
|
+
* The `aliases` doctor check ("Model aliases"), split out of
|
|
7
|
+
* src/cli-handlers-doctor.js to keep that file under the 300-line gate
|
|
8
|
+
* (mirrors doctor-engine-check.js / doctor-electron-mcp-check.js /
|
|
9
|
+
* doctor-base-url-check.js / doctor-local-providers-check.js -- same reason,
|
|
10
|
+
* a different check).
|
|
11
|
+
*
|
|
12
|
+
* B3 (council review of PR 198, issue 195): `doctor --fix` repairs exactly
|
|
13
|
+
* one narrow class of stored alias -- see alias-audit.js's
|
|
14
|
+
* `findFabricatedAliasRepairs` for the detection rule (classifies `invalid`
|
|
15
|
+
* on the `direct` gateway AND has an unambiguous OpenRouter twin) and why it
|
|
16
|
+
* cannot false-positive a typo, a retired model, or a user-invented id.
|
|
17
|
+
* Repair = rewrite `config.aliases[alias]` to that catalog-confirmed
|
|
18
|
+
* OpenRouter id -- read-modify-write, no-clobber (mirrors
|
|
19
|
+
* `applyProviderDefault`, provider-default-picker.js). Every OTHER
|
|
20
|
+
* stale/drifted alias is left untouched and stays a warning, hinting at
|
|
21
|
+
* `amicus models --check` same as before this PR.
|
|
22
|
+
*
|
|
23
|
+
* A3 (council review of PR 198): the repair ACTION additionally requires the
|
|
24
|
+
* cached catalog to be FRESH (same `MAX_CATALOG_AGE_MS` window as doctor's
|
|
25
|
+
* own `catalog` check, cli-handlers-doctor.js). `readCache()` here reads the
|
|
26
|
+
* exact same cache doctor's `catalog` check may independently report as
|
|
27
|
+
* `stale (Nh old)` -- without this gate, `--fix` would rewrite a user's
|
|
28
|
+
* config from data the SAME run just called untrustworthy. A stale catalog
|
|
29
|
+
* can be missing rows that would make a "fabricated" id look repairable when
|
|
30
|
+
* it is merely unfetched, so a stale catalog declines the repair (explaining
|
|
31
|
+
* why via `repairFabricatedAliasStaleCatalog`) rather than writing on
|
|
32
|
+
* unverified evidence; detection/reporting is unaffected either way.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const HINTS = require('./remediation-hints');
|
|
36
|
+
|
|
37
|
+
// Mirrors cli-handlers-doctor.js's own MAX_CATALOG_AGE_MS (which itself
|
|
38
|
+
// mirrors model-catalog.js's DEFAULT_MAX_AGE_MS) -- duplicated rather than
|
|
39
|
+
// imported to avoid a require cycle (cli-handlers-doctor.js requires this
|
|
40
|
+
// module at load time, before its own module.exports exists).
|
|
41
|
+
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{fetchedAt?: number}|null} cache
|
|
45
|
+
* @returns {boolean} true when `cache` exists, has a numeric `fetchedAt`, and
|
|
46
|
+
* is no older than `MAX_CATALOG_AGE_MS` -- the same test doctor's `catalog`
|
|
47
|
+
* check applies to decide `ok` vs `stale (Nh old)`.
|
|
48
|
+
*/
|
|
49
|
+
function isCatalogFresh(cache) {
|
|
50
|
+
if (!cache || typeof cache.fetchedAt !== 'number') { return false; }
|
|
51
|
+
return (Date.now() - cache.fetchedAt) <= MAX_CATALOG_AGE_MS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite one alias's stored value in place. Read-modify-write / no-clobber
|
|
56
|
+
* -- preserves `config.default` and every other alias/key (same contract as
|
|
57
|
+
* `applyProviderDefault`, provider-default-picker.js).
|
|
58
|
+
* @param {string} alias
|
|
59
|
+
* @param {string} newId verbatim catalog id (an OpenRouter-namespace id from
|
|
60
|
+
* `pairAcrossGateways` -- never hand-derived by string concatenation)
|
|
61
|
+
*/
|
|
62
|
+
function repairAlias(alias, newId) {
|
|
63
|
+
const { loadConfig, saveConfig } = require('./config');
|
|
64
|
+
const config = loadConfig() || {};
|
|
65
|
+
if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
|
|
66
|
+
config.aliases[alias] = newId;
|
|
67
|
+
saveConfig(config);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One pass: sources + both existing audits + the repairable set, over the same catalog. */
|
|
71
|
+
function computeState(d, catalog) {
|
|
72
|
+
const sources = d.collectAliasSources();
|
|
73
|
+
return {
|
|
74
|
+
sources,
|
|
75
|
+
stale: d.findStaleAliases(sources, catalog),
|
|
76
|
+
drifted: d.findDriftedStoredAliases(sources, catalog),
|
|
77
|
+
repairable: d.findFabricatedAliasRepairs(sources, catalog),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {{readCache: () => ({models?: Array}|null), collectAliasSources: () => Array,
|
|
83
|
+
* findStaleAliases: (s:Array, c:Array) => Array, findDriftedStoredAliases: (s:Array, c:Array) => Array,
|
|
84
|
+
* findFabricatedAliasRepairs: (s:Array, c:Array) => Array<{alias:string,oldId:string,newId:string}>,
|
|
85
|
+
* fix?: boolean, repairAlias?: (alias:string, newId:string) => void}} d
|
|
86
|
+
* @returns {{id,name,status,message,hint,fixed?,fixDetail?}}
|
|
87
|
+
*/
|
|
88
|
+
function evaluateAliasesCheck(d) {
|
|
89
|
+
const id = 'aliases';
|
|
90
|
+
const name = 'Model aliases';
|
|
91
|
+
const cache = d.readCache();
|
|
92
|
+
const catalog = (cache && cache.models) || [];
|
|
93
|
+
const catalogFresh = isCatalogFresh(cache);
|
|
94
|
+
|
|
95
|
+
let state = computeState(d, catalog);
|
|
96
|
+
let fixFields = {};
|
|
97
|
+
|
|
98
|
+
// Only under --fix, only when there is something in the narrow,
|
|
99
|
+
// mechanically-unambiguous class to repair (rule 1), and only on a FRESH
|
|
100
|
+
// catalog (A3) -- a failed individual rewrite is best-effort -- it simply
|
|
101
|
+
// stays a warning, same as one findStaleAliases could never resolve.
|
|
102
|
+
if (d.fix && state.repairable.length > 0 && catalogFresh) {
|
|
103
|
+
const repaired = [];
|
|
104
|
+
for (const r of state.repairable) {
|
|
105
|
+
try { d.repairAlias(r.alias, r.newId); repaired.push(r); }
|
|
106
|
+
catch { /* best-effort -- an unrepaired alias just stays a warning below */ }
|
|
107
|
+
}
|
|
108
|
+
if (repaired.length > 0) {
|
|
109
|
+
// Rule 6: announce every repair, naming the alias and both ids -- this
|
|
110
|
+
// fixDetail flows into the 'heal' degrade's `why` field (doctor-degrade.js).
|
|
111
|
+
const detail = repaired.map((r) => `'${r.alias}' (${r.oldId} -> ${r.newId})`).join('; ');
|
|
112
|
+
fixFields = {
|
|
113
|
+
fixed: true,
|
|
114
|
+
fixDetail: `rewrote ${repaired.length} fabricated alias(es) to its catalog-confirmed OpenRouter id: ${detail}`,
|
|
115
|
+
};
|
|
116
|
+
// Rule 5 (idempotency): recompute from a fresh config read so both this
|
|
117
|
+
// run's message and a second --fix run see the post-repair reality, not
|
|
118
|
+
// the pre-repair snapshot -- a repaired alias must not still count as
|
|
119
|
+
// stale/repairable below.
|
|
120
|
+
state = computeState(d, catalog);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { stale, drifted, repairable } = state;
|
|
125
|
+
if (stale.length === 0 && drifted.length === 0) {
|
|
126
|
+
return {
|
|
127
|
+
id, name, status: 'ok',
|
|
128
|
+
message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null,
|
|
129
|
+
...fixFields,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const parts = [];
|
|
133
|
+
if (stale.length) { parts.push(`${stale.length} stale: ${stale.map((s) => s.alias).join(', ')}`); }
|
|
134
|
+
if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map((s) => s.alias).join(', ')}`); }
|
|
135
|
+
// Rule 1: without --fix, report the repairable count and the hint, change
|
|
136
|
+
// nothing. A3: when the catalog is stale, say so explicitly rather than
|
|
137
|
+
// offering a fix that will silently decline to write.
|
|
138
|
+
if (repairable.length) {
|
|
139
|
+
parts.push(catalogFresh
|
|
140
|
+
? `${repairable.length} fixable via doctor --fix`
|
|
141
|
+
: `${repairable.length} fixable via doctor --fix once the catalog is refreshed (catalog is stale)`);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
id, name, status: 'warn', message: parts.join('; '),
|
|
145
|
+
hint: repairable.length === 0
|
|
146
|
+
? 'amicus models --check'
|
|
147
|
+
: (catalogFresh ? HINTS.repairFabricatedAlias : HINTS.repairFabricatedAliasStaleCatalog),
|
|
148
|
+
...fixFields,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { evaluateAliasesCheck, repairAlias };
|