amicus 3.1.0 → 3.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,50 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.2.0] - 2026-07-16
9
+
10
+ ### Added
11
+
12
+ - **Cost-aware per-provider default model picker at key-add.** Adding a provider API key (`amicus key
13
+ <provider>`, the setup wizard, or the Electron key step) now offers a picker that pre-selects a
14
+ **balanced**-tier model instead of the priciest flagship, shows live `$/M` input pricing, and writes
15
+ your choice as a vendor-named alias (e.g. `--model anthropic`) — seeding your overall default
16
+ (`config.default`) on your first key. Applies to direct model vendors (OpenAI, Anthropic, Google,
17
+ DeepSeek).
18
+ - **`routing.tier` preference** (`frontier` | `balanced` | `economy`, default `balanced`) biasing the
19
+ picker's per-vendor pre-selection.
20
+ - A one-time onboarding tip on `amicus start` pointing existing users to the new picker.
21
+
22
+ ### Changed
23
+
24
+ - Model aliases now resolve their per-gateway executable ids **by model**, so user-defined and
25
+ vendor-named aliases route correctly across the direct and OpenRouter gateways (extends the v3.1.1
26
+ gateway-correct-ids fix beyond the curated defaults). An alias whose value is an explicit
27
+ `openrouter/…` id still forces OpenRouter.
28
+
29
+ ## [3.1.1] - 2026-07-16
30
+
31
+ ### Fixed
32
+
33
+ - **Anthropic model aliases now route correctly for direct-Anthropic-key users.** `--model opus` /
34
+ `haiku` / `claude` / `sonnet` previously resolved to OpenRouter's dot-form id (e.g.
35
+ `anthropic/claude-opus-4.8`), which the direct Anthropic API rejects with `model_not_found` (it uses
36
+ dashes/date suffixes: `claude-opus-4-8`, `claude-haiku-4-5-20251001`). Aliases now carry per-gateway
37
+ executable ids and the router emits the selected gateway's native id. OpenRouter-only users were
38
+ unaffected.
39
+
40
+ ### Changed
41
+
42
+ - `--model claude` / `--model sonnet` default target moves from Claude Sonnet 4.6 to **Claude Sonnet
43
+ 5**; the offline model floor was refreshed to the current Anthropic family.
44
+ - Availability-aware routing: a model not served on the selected gateway (e.g. Fable, which is
45
+ OpenRouter-only) routes to the gateway that has it, or errors clearly under an explicit `--gateway`.
46
+
47
+ ### Added
48
+
49
+ - `amicus models --check --strict` exits non-zero on curated default-alias drift; a scheduled
50
+ `model-drift` CI workflow audits the per-gateway ids against the live (keyless) OpenRouter catalog.
51
+
8
52
  ## [3.1.0] - 2026-07-15
9
53
 
10
54
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A multi-model LLM Council for Claude — with a parallel AI window underneath.**
6
6
 
7
- ![The Amicus council mid-ritual: five models — Gemini 3 Pro, Llama 4, Grok 4, Claude Opus — reading the same material independently, chaired by GPT-5](./docs/council.png)
7
+ ![The Amicus council mid-ritual: five models — Gemini, Llama, Grok, Claude Opus — reading the same material independently, chaired by GPT](./docs/council.png)
8
8
 
9
9
  Hand Claude a plan, a design, a diff, an architecture decision, a manuscript — anything — and say *council review this*: Amicus routes it through several models from different families, has them anonymously cross-review each other, and a non-Claude chair synthesizes a verdict you turn into accept/deny edits. Or skip the ceremony and **fork** a single conversation to Gemini, GPT, DeepSeek, or any other model — it works in parallel with full context, and you **fold** the result back when you're ready. Claude orchestrates throughout; you stay in your editor.
10
10
 
@@ -319,7 +319,7 @@ $ amicus status demo123 --json
319
319
  "taskId": "demo123",
320
320
  "status": "complete",
321
321
  "elapsed": "5m 0s",
322
- "version": "3.1.0",
322
+ "version": "3.2.0",
323
323
  "model": "google/gemini-2.5-flash",
324
324
  "phase": "terminal"
325
325
  }
@@ -42,6 +42,26 @@ function registerSetupHandlers(getMainWindow) {
42
42
  require('../src/utils/model-catalog').refreshCatalog().catch(() => {});
43
43
  } catch { /* best-effort */ }
44
44
  });
45
+ // Task 8: per-provider default picker choices for the key step.
46
+ // Per-provider defaults only make sense for DIRECT model vendors --
47
+ // openrouter is the GATEWAY, not a vendor, so it's skipped entirely
48
+ // (mirrors provider-default-prompt.js's runProviderDefaultFlow gate;
49
+ // this path calls the picker core directly instead of that
50
+ // readline-oriented helper, so it re-checks isDirectProvider itself).
51
+ const { isDirectProvider } = require('../src/utils/provider-registry');
52
+ if (isDirectProvider(provider)) {
53
+ try {
54
+ const { getCatalog } = require('../src/utils/model-catalog');
55
+ const { buildProviderDefaultChoices } = require('../src/utils/provider-default-picker');
56
+ const catalog = await getCatalog();
57
+ result.providerDefault = buildProviderDefaultChoices(provider, { catalog });
58
+ } catch (err) {
59
+ logger.error('save-key providerDefault error', { error: err.message });
60
+ result.providerDefault = null;
61
+ }
62
+ } else {
63
+ result.providerDefault = null;
64
+ }
45
65
  }
46
66
  return result;
47
67
  } catch (err) {
@@ -50,6 +70,19 @@ function registerSetupHandlers(getMainWindow) {
50
70
  }
51
71
  });
52
72
 
73
+ // Task 8: apply a per-provider default picker choice. Read-modify-write,
74
+ // no-clobber -- applyProviderDefault only ever writes aliases[vendor] and
75
+ // seeds config.default when absent (see provider-default-picker.js).
76
+ ipcMain.handle('sidecar:set-provider-default', (_event, provider, chosenId) => {
77
+ try {
78
+ const { applyProviderDefault } = require('../src/utils/provider-default-picker');
79
+ return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
80
+ } catch (err) {
81
+ logger.error('set-provider-default handler error', { error: err.message });
82
+ return { success: false, error: err.message };
83
+ }
84
+ });
85
+
53
86
  ipcMain.handle('sidecar:remove-key', async (_event, provider) => {
54
87
  try {
55
88
  const { removeApiKey } = require('../src/utils/api-key-store');
@@ -22,7 +22,8 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
22
22
  'sidecar:get-api-keys',
23
23
  'sidecar:get-catalog',
24
24
  'sidecar:refresh-catalog',
25
- 'sidecar:fetch-free-models'
25
+ 'sidecar:fetch-free-models',
26
+ 'sidecar:set-provider-default'
26
27
  ];
27
28
  if (!allowedChannels.includes(channel)) {
28
29
  throw new Error(`IPC channel not allowed: ${channel}`);
@@ -9,7 +9,7 @@
9
9
  const ALIAS_GROUPS = [
10
10
  { name: 'Gemini', keys: ['gemini', 'gemini-pro'] },
11
11
  { name: 'GPT', keys: ['gpt', 'gpt-pro', 'codex'] },
12
- { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku'] },
12
+ { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku', 'fable'] },
13
13
  { name: 'DeepSeek', keys: ['deepseek'] },
14
14
  { name: 'Qwen', keys: ['qwen', 'qwen-coder', 'qwen-flash'] },
15
15
  { name: 'Mistral', keys: ['mistral', 'devstral'] },
@@ -19,6 +19,8 @@ function buildKeysScript() {
19
19
  var prov = providers.find(function(p) { return p.id === id; });
20
20
  if (!prov) { return; }
21
21
  selectedProvider = prov;
22
+ // Task 8: a picker rendered for the previously-selected provider is stale here.
23
+ if (window.hideProviderDefaultPicker) { window.hideProviderDefaultPicker(); }
22
24
  document.querySelectorAll('.provider-btn').forEach(function(b) { b.classList.remove('selected'); });
23
25
  this.classList.add('selected');
24
26
  keySection.classList.add('visible');
@@ -64,7 +66,7 @@ function buildKeysScript() {
64
66
  try {
65
67
  var res = await window.sidecarSetup.invoke('sidecar:validate-key', selectedProvider.id, key);
66
68
  if (res.valid) {
67
- await window.sidecarSetup.invoke('sidecar:save-key', selectedProvider.id, key);
69
+ var saveResult = await window.sidecarSetup.invoke('sidecar:save-key', selectedProvider.id, key);
68
70
  configuredKeys[selectedProvider.id] = true;
69
71
  var c = document.getElementById('check-' + selectedProvider.id);
70
72
  if (c) { c.textContent = '\\u2713'; }
@@ -72,6 +74,10 @@ function buildKeysScript() {
72
74
  setInputState('valid'); keyValid = true; validatedKey = key;
73
75
  keyHints[selectedProvider.id] = key.slice(0, 8) + '\\u2022'.repeat(Math.min(key.length - 8, 12));
74
76
  removeBtn.style.display = ''; updateNextState();
77
+ // Task 8: inline per-provider default picker beneath the just-saved key.
78
+ if (window.renderProviderDefaultPicker) {
79
+ window.renderProviderDefaultPicker(selectedProvider.id, selectedProvider.name, saveResult && saveResult.providerDefault);
80
+ }
75
81
  } else {
76
82
  statusMsg.textContent = res.error || 'Invalid key'; statusMsg.className = 'status-invalid';
77
83
  setInputState('invalid'); keyValid = false;
@@ -107,6 +113,7 @@ function buildKeysScript() {
107
113
  keyInput.value = ''; keyInput.type = 'password'; keyValid = false; setInputState(null);
108
114
  statusMsg.textContent = 'Key removed'; statusMsg.className = 'status-testing';
109
115
  removeBtn.style.display = 'none'; updateNextState();
116
+ if (window.hideProviderDefaultPicker) { window.hideProviderDefaultPicker(); }
110
117
  } catch (_e) { statusMsg.textContent = 'Failed to remove'; statusMsg.className = 'status-invalid'; }
111
118
  removeBtn.disabled = false;
112
119
  });`;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Setup UI — Per-provider default model picker (Part 2, Task 8).
3
+ *
4
+ * Rendered inline in the key step (Step 1), beneath the just-saved key, once
5
+ * `sidecar:save-key` (ipc-setup.js) returns a non-null `providerDefault` for a
6
+ * DIRECT provider (google/openai/anthropic/deepseek — never the openrouter
7
+ * gateway; see ipc-setup.js's own isDirectProvider guard, mirroring the skip
8
+ * rule in provider-default-prompt.js's runProviderDefaultFlow).
9
+ *
10
+ * Split into its own file (rather than growing setup-ui-keys.js /
11
+ * setup-ui-keys-script.js) per the existing setup-ui-* convention (see
12
+ * setup-ui-council.js, another Step-content add-on with its own HTML +
13
+ * script pair).
14
+ *
15
+ * Row data (from src/utils/provider-default-picker.js's
16
+ * buildProviderDefaultChoices): { preselectedId, rows: [{id, name,
17
+ * contextLength, pricePerMInput, isPreselected}] }. Row name/pricing come
18
+ * straight from the live model catalog, so the runtime script below builds
19
+ * rows via createElement/textContent (never innerHTML string interpolation)
20
+ * — the same convention Step 2's renderSearchResults uses for catalog rows.
21
+ */
22
+ 'use strict';
23
+
24
+ /** Static (initially hidden) container the key step's picker renders into. */
25
+ function buildProviderDefaultSectionHTML() {
26
+ return `<div class="provider-default-section" id="provider-default-section" style="display:none">
27
+ <div class="provider-default-label" id="provider-default-label">Default model</div>
28
+ <div class="provider-default-list" id="provider-default-list"></div>
29
+ </div>`;
30
+ }
31
+
32
+ /**
33
+ * Browser-only JS: exposes window.renderProviderDefaultPicker(provider,
34
+ * providerName, providerDefault) and window.hideProviderDefaultPicker(),
35
+ * wired to apply the pick via sidecar:set-provider-default
36
+ * (src/utils/provider-default-picker.js's applyProviderDefault,
37
+ * read-modify-write, no-clobber — see ipc-setup.js). Selecting a row (or
38
+ * leaving the preselection untouched) both apply the same way: the
39
+ * preselected id is applied immediately on render, and a change event
40
+ * re-applies whichever row the user picks instead.
41
+ * @returns {string} JavaScript source (no <script> tags)
42
+ */
43
+ function buildProviderDefaultScript() {
44
+ return `
45
+ // Task 8: per-provider default picker, inline in the key step.
46
+ (function() {
47
+ var section = document.getElementById('provider-default-section');
48
+ var label = document.getElementById('provider-default-label');
49
+ var list = document.getElementById('provider-default-list');
50
+
51
+ function fmtPrice(p) { return (p === null || p === undefined) ? 'n/a' : '$' + Number(p).toFixed(2) + '/M in'; }
52
+ function fmtCtx(n) { return (n === null || n === undefined) ? '' : ' \\u00b7 ctx ' + n; }
53
+
54
+ function applyChoice(provider, chosenId) {
55
+ if (!provider || !chosenId) { return; }
56
+ window.sidecarSetup.invoke('sidecar:set-provider-default', provider, chosenId).catch(function() {});
57
+ }
58
+
59
+ function buildRow(provider, row) {
60
+ var rowEl = document.createElement('label');
61
+ rowEl.className = 'provider-default-row';
62
+ var radio = document.createElement('input');
63
+ radio.type = 'radio'; radio.name = 'provider-default-' + provider;
64
+ radio.value = row.id; radio.checked = !!row.isPreselected;
65
+ var text = document.createElement('span');
66
+ text.className = 'provider-default-text';
67
+ text.textContent = row.name + fmtCtx(row.contextLength) + ' \\u00b7 ' + fmtPrice(row.pricePerMInput);
68
+ rowEl.appendChild(radio); rowEl.appendChild(text);
69
+ if (row.isPreselected) {
70
+ var badge = document.createElement('span');
71
+ badge.className = 'pick-badge'; badge.textContent = 'recommended';
72
+ rowEl.appendChild(badge);
73
+ }
74
+ radio.addEventListener('change', function() { if (radio.checked) { applyChoice(provider, row.id); } });
75
+ return rowEl;
76
+ }
77
+
78
+ window.hideProviderDefaultPicker = function() {
79
+ if (!section) { return; }
80
+ section.style.display = 'none';
81
+ if (list) { list.innerHTML = ''; }
82
+ };
83
+
84
+ window.renderProviderDefaultPicker = function(provider, providerName, providerDefault) {
85
+ if (!section || !list) { return; }
86
+ if (!providerDefault || !Array.isArray(providerDefault.rows) || providerDefault.rows.length === 0) {
87
+ window.hideProviderDefaultPicker(); return;
88
+ }
89
+ if (label) { label.textContent = 'Default model for ' + (providerName || provider); }
90
+ list.innerHTML = '';
91
+ providerDefault.rows.forEach(function(row) { list.appendChild(buildRow(provider, row)); });
92
+ section.style.display = '';
93
+ // No-selection case: the preselected row is already checked above, so
94
+ // applying it immediately covers "advance without touching the list".
95
+ applyChoice(provider, providerDefault.preselectedId);
96
+ };
97
+ })();
98
+ `;
99
+ }
100
+
101
+ module.exports = { buildProviderDefaultSectionHTML, buildProviderDefaultScript };
@@ -415,7 +415,19 @@ function __rawWizardCSS() {
415
415
  content: ''; position: absolute; inset: 0;
416
416
  border-radius: 50%; background: var(--ok);
417
417
  animation: amicusPulse 1.6s var(--ease-out) infinite;
418
- }`;
418
+ }
419
+
420
+ /* Per-provider default picker (Step 1, Task 8) */
421
+ .provider-default-section { margin-top: 14px; }
422
+ .provider-default-label { font-size: 12px; color: var(--text-muted); margin-bottom: 6px; }
423
+ .provider-default-list { display: flex; flex-direction: column; gap: 2px; }
424
+ .provider-default-row {
425
+ display: flex; align-items: center; gap: 8px;
426
+ padding: 6px 10px; font-size: 12px; color: var(--text); cursor: pointer;
427
+ border-radius: var(--r-6);
428
+ }
429
+ .provider-default-row:hover { background: var(--surface-hover); }
430
+ .provider-default-row input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; }`;
419
431
  }
420
432
 
421
433
  function buildWizardCSS() {
@@ -6,6 +6,7 @@ const { buildWizardCSS } = require('./setup-ui-styles');
6
6
  const { buildKeysScript } = require('./setup-ui-keys-script');
7
7
  const { buildAliasScript } = require('./setup-ui-alias-script');
8
8
  const { buildCouncilSectionHTML, buildCouncilScript } = require('./setup-ui-council');
9
+ const { buildProviderDefaultSectionHTML, buildProviderDefaultScript } = require('./setup-ui-provider-default');
9
10
  const { getDefaultAliases } = require('../src/utils/config');
10
11
  const { getBrandName } = require('./toolbar');
11
12
  const { resolveQuickPicks } = require('../src/utils/quick-picks');
@@ -40,7 +41,7 @@ function buildSetupHTML(options = {}) {
40
41
  <div class="header"><svg width="18" height="18" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg><span class="header-title">${brandName} Setup</span></div>
41
42
  <div class="progress-bar"><div class="progress-step active" id="step-1"><span class="progress-dot">1</span><span>API Keys</span></div><div class="progress-connector"></div><div class="progress-step" id="step-2"><span class="progress-dot">2</span><span>Models</span></div><div class="progress-connector"></div><div class="progress-step" id="step-3"><span class="progress-dot">3</span><span>Routing</span></div><div class="progress-connector"></div><div class="progress-step" id="step-4"><span class="progress-dot">4</span><span>Review</span></div></div>
42
43
  <div class="content">
43
- <div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}</div>
44
+ <div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}${buildProviderDefaultSectionHTML()}</div>
44
45
  <div class="wizard-step" id="wizard-step-2">${modelHtml}${buildCouncilSectionHTML()}</div>
45
46
  <div class="wizard-step" id="wizard-step-3">${aliasHtml}</div>
46
47
  <div class="wizard-step" id="wizard-step-4">
@@ -63,6 +64,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
63
64
  const keysJs = buildKeysScript();
64
65
  const aliasJs = buildAliasScript();
65
66
  const councilJs = buildCouncilScript();
67
+ const providerDefaultJs = buildProviderDefaultScript();
66
68
  return `<script>
67
69
  window.onerror = function(msg, src, line, col, err) { console.error('WIZARD ERROR:', msg, 'at', src, line, col, err); };
68
70
  window.onunhandledrejection = function(e) { console.error('WIZARD UNHANDLED REJECTION:', e.reason); };
@@ -574,6 +576,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
574
576
  ${keysJs}
575
577
 
576
578
  ${councilJs}
579
+
580
+ ${providerDefaultJs}
577
581
  </script>`;
578
582
  }
579
583
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -59,6 +59,7 @@
59
59
  "refresh-models": "node bin/amicus.js models --refresh",
60
60
  "models:info": "node bin/amicus.js models",
61
61
  "models:check": "node bin/amicus.js models --check",
62
+ "models:check:strict": "node bin/amicus.js models --check --strict",
62
63
  "generate-icon": "node scripts/generate-icon.js",
63
64
  "generate-docs": "node scripts/generate-docs.js",
64
65
  "generate-docs:check": "node scripts/generate-docs.js --check",
@@ -14,7 +14,7 @@
14
14
 
15
15
  const { validateStartArgs } = require('./cli');
16
16
  const { validateTaskId } = require('./utils/validators');
17
- const { resolveLaunchModel } = require('./utils/start-helpers');
17
+ const { resolveLaunchModel, maybeOfferProviderDefaults } = require('./utils/start-helpers');
18
18
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
19
19
  const { requireNoUiForJson } = require('./utils/cli-preflight');
20
20
  const { GATEWAY_MODES } = require('./utils/model-descriptor');
@@ -55,6 +55,14 @@ async function handleStart(args) {
55
55
  process.exit(failJson(useJson, { code: validation.code || ERROR_CODES.BAD_ARGS, message: validation.error }));
56
56
  }
57
57
 
58
+ // Existing-user one-time onboarding offer (Part 2, Task 9): a non-blocking
59
+ // notice, printed at most once ever, pointing users who already have direct
60
+ // provider keys at the per-provider cost-aware default picker. No-ops on
61
+ // any non-interactive/--json run (see maybeOfferProviderDefaults). Fired
62
+ // only after validateStartArgs succeeds so a failed first `amicus start`
63
+ // doesn't burn the one-time flag.
64
+ maybeOfferProviderDefaults(args);
65
+
58
66
  // Budget gate for solo start
59
67
  if (!args['no-cost-gate']) {
60
68
  const { lookupPricing } = require('./utils/pricing');
@@ -159,6 +159,46 @@ async function handleKey(args) {
159
159
  process.exit(1);
160
160
  }
161
161
  console.log(`${provider} key validated and saved.`);
162
+
163
+ await offerProviderDefault(provider, args);
164
+ }
165
+
166
+ /**
167
+ * After a successful key save, run the per-provider cost-aware default
168
+ * picker (Part 2, Task 6) and print its one-line summary. Wrapped end-to-end
169
+ * in try/catch: the key is ALREADY saved by the time this runs, so a picker
170
+ * failure (offline catalog, bad input, etc.) must never abort `amicus key`
171
+ * -- it just prints a soft notice instead.
172
+ * @param {string} provider
173
+ * @param {object} args parsed CLI args (checked for --json/--quiet to gate interactivity)
174
+ */
175
+ async function offerProviderDefault(provider, args) {
176
+ let rl = null;
177
+ try {
178
+ const { runProviderDefaultFlow } = require('./utils/provider-default-prompt');
179
+ const { getCatalog } = require('./utils/model-catalog');
180
+
181
+ const interactive = !!process.stdin.isTTY && !args.json && !args.quiet;
182
+
183
+ let catalog = [];
184
+ try { catalog = await getCatalog(); } catch { /* offline/empty: handled gracefully by the picker */ }
185
+
186
+ let ask;
187
+ if (interactive) {
188
+ const readline = require('readline');
189
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
190
+ ask = (q) => new Promise((resolve) => rl.question(q, (answer) => resolve((answer || '').trim())));
191
+ }
192
+
193
+ const { summaryLine } = await runProviderDefaultFlow(provider, {
194
+ interactive, ask, catalog, print: console.log,
195
+ });
196
+ console.log(summaryLine);
197
+ } catch (err) {
198
+ console.log(`Note: couldn't set a per-provider default (${err.message}). Run 'amicus key ${provider}' again later.`);
199
+ } finally {
200
+ if (rl) { rl.close(); }
201
+ }
162
202
  }
163
203
 
164
204
  module.exports = {
package/src/cli.js CHANGED
@@ -138,6 +138,7 @@ function isBooleanFlag(key) {
138
138
  'html', // council report: emit a self-contained HTML page
139
139
  'md', // council report: emit Markdown (default)
140
140
  'fix', // doctor: self-heal fixable checks in place (#56)
141
+ 'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
141
142
  ];
142
143
  return booleanFlags.includes(key);
143
144
  }
@@ -436,6 +437,9 @@ Options for 'models':
436
437
  --search <q> Filter by substring over model id and name
437
438
  --refresh Force-refresh the catalog from provider APIs
438
439
  --check Audit aliases against the catalog (exit = stale count)
440
+ --strict With --check: also exit non-zero on curated
441
+ per-gateway drift (stale/divergent direct or
442
+ openrouter forms). Informational without it.
439
443
  --json Machine-readable output
440
444
  `,
441
445
  list: `
@@ -14,6 +14,7 @@
14
14
 
15
15
  const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
16
16
  const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
17
+ const { auditGatewayRoutes } = require('../utils/gateway-route-audit');
17
18
  const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
18
19
  const { getFamilies } = require('../utils/curated-models');
19
20
  const { pickCurrent } = require('../utils/quick-picks');
@@ -116,8 +117,20 @@ async function runRefresh(args) {
116
117
  return 0;
117
118
  }
118
119
 
120
+ /** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
121
+ function fmtGatewayFinding(f) {
122
+ if (f.kind === 'stale') {
123
+ return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
124
+ }
125
+ if (f.kind === 'divergent-missing') {
126
+ return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
127
+ }
128
+ return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
129
+ }
130
+
119
131
  async function runCheck(args) {
120
- const { models: catalog } = await getCatalogInfo();
132
+ const catalogInfo = await getCatalogInfo();
133
+ const catalog = catalogInfo.models;
121
134
  if (!catalog || catalog.length === 0) {
122
135
  if (args.json) {
123
136
  process.stdout.write(JSON.stringify(buildAuditDoc({
@@ -131,35 +144,44 @@ async function runCheck(args) {
131
144
  const sources = collectAliasSources();
132
145
  const stale = findStaleAliases(sources, catalog)
133
146
  .map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
147
+ // Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
148
+ // (toGatewayRoutes()) — additive to the flat audit above. Informational by
149
+ // default; --strict promotes it to a build-breaking exit code (CI gate).
150
+ const gatewayFindings = auditGatewayRoutes(catalogInfo);
151
+ const legacyExitCode = Math.min(stale.length, CHECK_EXIT_CAP);
152
+ const exitCode = args.strict
153
+ ? Math.max(legacyExitCode, Math.min(gatewayFindings.length, CHECK_EXIT_CAP))
154
+ : legacyExitCode;
155
+
134
156
  if (args.json) {
135
157
  process.stdout.write(JSON.stringify(buildAuditDoc({
136
- stale, catalogAvailable: true
158
+ stale, catalogAvailable: true, gatewayFindings
137
159
  }), null, 2) + '\n');
138
- return Math.min(stale.length, CHECK_EXIT_CAP);
160
+ return exitCode;
139
161
  }
140
162
  const driftLines = buildFallbackDriftReport(catalog);
141
163
  if (stale.length === 0) {
142
164
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
143
- if (driftLines.length > 0) {
144
- process.stdout.write('Pinned fallback drift:\n');
145
- for (const l of driftLines) { process.stdout.write(l + '\n'); }
146
- }
147
- return 0;
148
- }
149
- for (const s of stale) {
150
- process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
151
- if (s.suggestions.length > 0) {
152
- process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
153
- process.stdout.write(` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]}\n`);
154
- } else {
155
- process.stdout.write(' no same-vendor candidates in catalog\n');
165
+ } else {
166
+ for (const s of stale) {
167
+ process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
168
+ if (s.suggestions.length > 0) {
169
+ process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
170
+ process.stdout.write(` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]}\n`);
171
+ } else {
172
+ process.stdout.write(' no same-vendor candidates in catalog\n');
173
+ }
156
174
  }
157
175
  }
158
176
  if (driftLines.length > 0) {
159
177
  process.stdout.write('Pinned fallback drift:\n');
160
178
  for (const l of driftLines) { process.stdout.write(l + '\n'); }
161
179
  }
162
- return Math.min(stale.length, CHECK_EXIT_CAP);
180
+ if (gatewayFindings.length > 0) {
181
+ process.stdout.write('Per-gateway route audit (curated defaults):\n');
182
+ for (const f of gatewayFindings) { process.stdout.write(fmtGatewayFinding(f) + '\n'); }
183
+ }
184
+ return exitCode;
163
185
  }
164
186
 
165
187
  /**