amicus 3.1.1 → 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.1",
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,27 @@ 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
+
8
29
  ## [3.1.1] - 2026-07-16
9
30
 
10
31
  ### Fixed
package/README.md CHANGED
@@ -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.1",
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}`);
@@ -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.1",
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": [
@@ -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 = {
@@ -187,8 +187,12 @@ async function seedCatalog(print) {
187
187
  * (Enter = the vendor-diverse default), seeds aliases + councils.free, and
188
188
  * never touches config.default.
189
189
  * @param {readline.Interface} rl
190
+ * @param {Array<object>} [catalogArg] pre-fetched catalog from the caller
191
+ * (`runReadlineSetup` already fetches one for the per-provider phase) --
192
+ * reused as-is to avoid a second `getCatalog()` round trip. Falls back to
193
+ * fetching its own when omitted (e.g. direct unit-test callers).
190
194
  */
191
- async function runFreeCouncilBranch(rl) {
195
+ async function runFreeCouncilBranch(rl, catalogArg) {
192
196
  const keys = detectApiKeys();
193
197
  if (!keys.openrouter) {
194
198
  console.log('');
@@ -196,10 +200,14 @@ async function runFreeCouncilBranch(rl) {
196
200
  console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
197
201
  return;
198
202
  }
199
- const { getCatalog } = require('../utils/model-catalog');
200
203
  const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
201
204
  let catalog = [];
202
- try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
205
+ if (Array.isArray(catalogArg)) {
206
+ catalog = catalogArg;
207
+ } else {
208
+ const { getCatalog } = require('../utils/model-catalog');
209
+ try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
210
+ }
203
211
  let free = listFreeModels(catalog);
204
212
  if (free.length === 0) {
205
213
  console.log('Live free-model list unavailable (offline?) — using a small pinned set.');
@@ -237,14 +245,63 @@ async function runFreeCouncilBranch(rl) {
237
245
  console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
238
246
  }
239
247
 
248
+ /**
249
+ * Run the shared per-provider default-model picker (Task 6, `runProviderDefaultFlow`)
250
+ * once for each keyed provider, in `foundKeys` detection order -- whichever
251
+ * comes first seeds `config.default` (`applyProviderDefault`'s
252
+ * seed-only-when-absent rule; see `provider-default-picker.js`). Each provider
253
+ * is its own read-modify-write against the real config file, so an alias
254
+ * written here is on disk before the standard model step's own `loadConfig()`
255
+ * runs -- no clobber, no shared in-memory object to race.
256
+ *
257
+ * Skippable and crash-proof: `runProviderDefaultFlow` already degrades to a
258
+ * graceful summary line on an empty/offline catalog (never calls `ask`), and
259
+ * a per-provider try/catch means one provider's failure can't block the rest
260
+ * of setup (mirrors `cli-handlers.js`'s `offerProviderDefault`). It's also a
261
+ * graceful no-op for a gateway provider (`openrouter`) -- see
262
+ * `runProviderDefaultFlow`'s `isDirectProvider` gate.
263
+ * @param {readline.Interface} rl
264
+ * @param {string[]} foundKeys keyed providers, in detection order
265
+ * @param {Array<object>} catalog
266
+ * @returns {Promise<Set<string>>} vendor alias NAMES actually written this run
267
+ * (i.e. `runProviderDefaultFlow` returned a non-null `chosenId`) -- the
268
+ * standard model step must not clobber these when a quick-pick family
269
+ * alias collides with one (see the standard-model-step alias-upgrade guard).
270
+ */
271
+ async function runProviderDefaultPickers(rl, foundKeys, catalog) {
272
+ const written = new Set();
273
+ if (foundKeys.length === 0) { return written; }
274
+ const { runProviderDefaultFlow } = require('../utils/provider-default-prompt');
275
+ for (const provider of foundKeys) {
276
+ try {
277
+ const { chosenId, summaryLine } = await runProviderDefaultFlow(provider, {
278
+ interactive: true,
279
+ ask: (q) => askQuestion(rl, q),
280
+ catalog,
281
+ print: console.log,
282
+ });
283
+ if (chosenId) { written.add(provider); }
284
+ console.log(summaryLine);
285
+ } catch (err) {
286
+ console.log(
287
+ `Note: couldn't set a default for ${provider} (${err.message}). ` +
288
+ `Run \`amicus key ${provider}\` again later.`
289
+ );
290
+ }
291
+ }
292
+ console.log('');
293
+ return written;
294
+ }
295
+
240
296
  /**
241
297
  * Run the readline-based setup wizard (headless fallback)
242
298
  *
243
299
  * Guides the user through:
244
300
  * 1. API key detection
245
- * 2. Mode selection (standard or free council)
246
- * 3. Default model selection from live quick-picks (read-modify-write, no clobber)
247
- * 4. Config file save
301
+ * 2. Per-provider default-model picker (Task 7) -- once per keyed provider
302
+ * 3. Mode selection (standard or free council)
303
+ * 4. Default model selection from live quick-picks (read-modify-write, no clobber)
304
+ * 5. Config file save
248
305
  */
249
306
  async function runReadlineSetup() {
250
307
  const rl = readline.createInterface({
@@ -279,18 +336,25 @@ async function runReadlineSetup() {
279
336
  await warnOnLowOpenRouterCredit();
280
337
  }
281
338
 
339
+ const { getCatalog } = require('../utils/model-catalog');
340
+ let catalog = [];
341
+ try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
342
+
343
+ // Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
344
+ // provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
345
+ // `vendorAliasesWritten` is consulted by the standard model step below so
346
+ // it never clobbers a vendor alias this phase just wrote (Fix 2).
347
+ const vendorAliasesWritten = await runProviderDefaultPickers(rl, foundKeys, catalog);
348
+
282
349
  const mode = await askQuestion(rl,
283
350
  'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
284
351
  if (mode === '2') {
285
- await runFreeCouncilBranch(rl);
352
+ await runFreeCouncilBranch(rl, catalog);
286
353
  return;
287
354
  }
288
355
 
289
- const { getCatalog } = require('../utils/model-catalog');
290
356
  const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
291
357
  const { toCanonicalDefault } = require('../utils/curated-models');
292
- let catalog = [];
293
- try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
294
358
  const picks = resolveQuickPicks(catalog);
295
359
 
296
360
  console.log('Choose your default model:');
@@ -316,7 +380,12 @@ async function runReadlineSetup() {
316
380
  if (chosen.alias) {
317
381
  cfg.default = chosen.alias;
318
382
  const pick = picks.find(p => p.alias === chosen.alias);
319
- if (pick && !chosen.noUpgrade) {
383
+ // Fix 2: a quick-pick family alias (e.g. 'deepseek') can collide with a
384
+ // vendor alias the per-provider phase just wrote this run. `config.default`
385
+ // pointing at that alias name is fine (the user's explicit overall-default
386
+ // choice), but the alias's VALUE must stay the vendor phase's tier choice --
387
+ // skip the curated-flagship upgrade so it isn't discarded.
388
+ if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
320
389
  cfg.aliases[chosen.alias] = toCanonicalDefault(pick.routes.openrouter || Object.values(pick.routes)[0]);
321
390
  } else if (cfg.aliases[chosen.alias] === undefined) {
322
391
  const fallback = getDefaultAliases()[chosen.alias];
@@ -419,6 +419,60 @@ function markMigrationNotified(vendor) {
419
419
  }
420
420
  }
421
421
 
422
+ /**
423
+ * Existing-user one-time onboarding offer (Part 2, Task 9). Mirrors
424
+ * markMigrationNotified's flag pattern: a single boolean persisted at
425
+ * config.routing.tier_onboarded once the notice has fired, so it never
426
+ * repeats.
427
+ * @returns {boolean} true once the notice has fired
428
+ */
429
+ function hasTierOnboarded() {
430
+ const config = loadConfig() || {};
431
+ return !!(config.routing && config.routing.tier_onboarded === true);
432
+ }
433
+
434
+ /**
435
+ * Persist the one-time onboarding-notice flag, preserving any other routing
436
+ * keys (prefer, tier, migration_notified). Best-effort: swallows any
437
+ * saveConfig failure so a persistence hiccup never breaks the command that
438
+ * triggered it (mirrors markMigrationNotified).
439
+ */
440
+ function markTierOnboarded() {
441
+ try {
442
+ const config = loadConfig() || {};
443
+ if (!config.routing || typeof config.routing !== 'object') { config.routing = {}; }
444
+ config.routing.tier_onboarded = true;
445
+ saveConfig(config);
446
+ } catch (_err) {
447
+ // best-effort: never fail the command over a persistence error
448
+ }
449
+ }
450
+
451
+ /** Global cost-tier preference (Part 2, Task 1) — priciest-to-cheapest. */
452
+ const COST_TIERS = ['frontier', 'balanced', 'economy'];
453
+
454
+ /** @returns {'frontier'|'balanced'|'economy'} config.routing.tier, defaulting/coercing to 'balanced' */
455
+ function getCostTier() {
456
+ const config = loadConfig() || {};
457
+ const tier = config.routing && config.routing.tier;
458
+ return COST_TIERS.includes(tier) ? tier : 'balanced';
459
+ }
460
+
461
+ /**
462
+ * Persist the global cost-tier preference under routing.tier, preserving any
463
+ * other routing keys (prefer, migration_notified).
464
+ * @param {string} tier one of COST_TIERS
465
+ * @throws {Error} when tier is not a recognized cost tier
466
+ */
467
+ function setCostTier(tier) {
468
+ if (!COST_TIERS.includes(tier)) {
469
+ throw new Error(`Invalid cost tier '${tier}'. Must be one of: ${COST_TIERS.join(', ')}`);
470
+ }
471
+ const config = loadConfig() || {};
472
+ config.routing = { ...(config.routing || {}), tier };
473
+ saveConfig(config);
474
+ }
475
+
422
476
  module.exports = {
423
477
  getConfigDir,
424
478
  getConfigPath,
@@ -440,4 +494,9 @@ module.exports = {
440
494
  getRoutingConfig,
441
495
  resolveGatewayMode,
442
496
  markMigrationNotified,
497
+ COST_TIERS,
498
+ getCostTier,
499
+ setCostTier,
500
+ hasTierOnboarded,
501
+ markTierOnboarded,
443
502
  };
@@ -160,8 +160,10 @@ function listCuratedRoutes() {
160
160
  * versioning, distinct model names, etc.). NEVER derive a direct form for
161
161
  * these — derivation would emit the wrong (dot) id, or invent a direct id
162
162
  * for a model that is OpenRouter-only today (e.g. fable).
163
+ * Frozen so consumers can only read it (`.has()`) — a frozen Set still
164
+ * supports lookups, it just can't be `.add()`/`.delete()`/`.clear()`-ed.
163
165
  */
164
- const DIVERGENT_VENDORS = new Set(['anthropic']);
166
+ const DIVERGENT_VENDORS = Object.freeze(new Set(['anthropic']));
165
167
 
166
168
  /**
167
169
  * @param {string} orRoute e.g. 'openrouter/anthropic/claude-sonnet-5'
@@ -212,5 +214,5 @@ function toGatewayRoutes() {
212
214
  }
213
215
 
214
216
  module.exports = {
215
- getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes
217
+ getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes, DIVERGENT_VENDORS
216
218
  };