amicus 4.9.2 → 4.9.4

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.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/bin/amicus.js +6 -0
  5. package/docs/ROADMAP.md +5 -4
  6. package/docs/architecture-map.md +732 -0
  7. package/docs/configuration.md +175 -1
  8. package/docs/council.md +9 -0
  9. package/docs/doc-system.md +12 -9
  10. package/docs/testing.md +2 -1
  11. package/docs/troubleshooting.md +76 -0
  12. package/docs/usage.md +14 -6
  13. package/electron/main.js +25 -2
  14. package/electron/setup-ui-alias-groups.js +161 -0
  15. package/electron/setup-ui-alias-script.js +70 -4
  16. package/electron/setup-ui-aliases.js +25 -21
  17. package/electron/setup-ui.js +11 -1
  18. package/package.json +1 -1
  19. package/schemas/model-catalog.schema.json +2 -1
  20. package/schemas/run.schema.json +13 -0
  21. package/skills/sidecar/SKILL.md +1 -8
  22. package/src/cli-handlers-doctor.js +12 -16
  23. package/src/cli-handlers-fanout.js +10 -1
  24. package/src/cli-handlers-resume-continue.js +25 -0
  25. package/src/cli-handlers.js +17 -1
  26. package/src/cli.js +5 -8
  27. package/src/council/briefings-chair.js +4 -2
  28. package/src/council/run-assemble.js +7 -2
  29. package/src/council/run-retry-notes.js +21 -1
  30. package/src/council/run-stages.js +8 -1
  31. package/src/headless.js +125 -7
  32. package/src/mcp-server.js +26 -0
  33. package/src/mcp-tools.js +4 -4
  34. package/src/opencode-client.js +84 -8
  35. package/src/pack/pack-validate.js +3 -0
  36. package/src/session-manager.js +2 -2
  37. package/src/sidecar/continue.js +6 -1
  38. package/src/sidecar/conversation-mirror.js +35 -11
  39. package/src/sidecar/fanout-leg-fallback.js +1 -0
  40. package/src/sidecar/fanout-leg.js +10 -2
  41. package/src/sidecar/fanout.js +2 -2
  42. package/src/sidecar/interactive.js +31 -4
  43. package/src/sidecar/models-ceiling-line.js +72 -0
  44. package/src/sidecar/models.js +4 -2
  45. package/src/sidecar/reopen-notices.js +97 -0
  46. package/src/sidecar/reopen-spend.js +3 -2
  47. package/src/sidecar/resume.js +15 -2
  48. package/src/sidecar/session-finalize.js +4 -1
  49. package/src/sidecar/session-utils.js +5 -1
  50. package/src/sidecar/start-metadata.js +1 -1
  51. package/src/sidecar/start.js +10 -5
  52. package/src/utils/api-key-validation.js +183 -94
  53. package/src/utils/config.js +65 -2
  54. package/src/utils/curated-models.js +8 -8
  55. package/src/utils/degrade.js +7 -0
  56. package/src/utils/doctor-credit-check.js +61 -0
  57. package/src/utils/doctor-key-auth-check.js +271 -0
  58. package/src/utils/doctor-output-budget-check.js +198 -0
  59. package/src/utils/engine-output-flag.js +105 -0
  60. package/src/utils/engine-variants.js +298 -0
  61. package/src/utils/http-get.js +284 -0
  62. package/src/utils/live-probes.js +53 -0
  63. package/src/utils/model-catalog.js +36 -4
  64. package/src/utils/model-ceilings-modelsdev.js +230 -0
  65. package/src/utils/model-fetcher.js +14 -36
  66. package/src/utils/model-output-limit.js +132 -0
  67. package/src/utils/openrouter-credit.js +104 -0
  68. package/src/utils/output-length.js +90 -0
  69. package/src/utils/result-schema.js +7 -2
  70. package/src/utils/spend-ledger.js +5 -1
  71. package/src/utils/thinking-validators.js +27 -80
  72. package/src/utils/validators.js +2 -3
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Setup UI - Alias grouping rule (issue 213)
3
+ *
4
+ * The Step 3 alias editor used to bucket rows with a hardcoded list of alias
5
+ * NAMES, and `Other` was itself a fixed key list rather than a catch-all — so
6
+ * any alias whose name was not on the list (a local-provider route, a `free-*`
7
+ * council member, a case variant like `GLM`) rendered nowhere at all.
8
+ *
9
+ * Grouping is now derived from the alias's ROUTE VENDOR, which every alias has.
10
+ *
11
+ * REUSE NOTE: the vendor parse is `vendorOf` from src/sidecar/fallback-chains.js
12
+ * — the existing primitive, imported, not re-implemented. It PARSES a vendor
13
+ * segment (it never emits an id that gets called), which is the same
14
+ * ban-exempt category as the other allowlisted `vendorOf` callers in
15
+ * .eslintrc.js. `groupModelsByFamily` (src/utils/model-fetcher.js) is
16
+ * deliberately NOT reused: it keys on `id.split('/')[0]`, so every
17
+ * `openrouter/...` alias would collapse into a single "OpenRouter" bucket —
18
+ * exactly the grouping this file exists to avoid. Its DISPLAY half
19
+ * (PROVIDER_FAMILY_NAMES) is reused below.
20
+ *
21
+ * SHARED-WITH-THE-BROWSER NOTE — deliberately NOT shared. The wizard's inline
22
+ * script cannot `require`, so the browser could only get this rule as a copy:
23
+ * hand-written (silent divergence — a 3-segment direct id like `a/b/c` already
24
+ * splits differently under the two obvious spellings) or serialised from the
25
+ * source below (which would put `slice('openrouter/'.length)` back into the
26
+ * page). The page carrying its own gateway-prefix strip is the exact shape
27
+ * issue 214 removed and that tests/setup-ui.test.js still guards
28
+ * ("ships no routing policy to the page: ... no prefix derivation"), because
29
+ * that copy is how a direct id gets fabricated for a namespace that never
30
+ * served it.
31
+ *
32
+ * So there is ONE grouping rule and it lives here, server-side. The client
33
+ * (setup-ui-alias-script.js) never derives a vendor: a route added during the
34
+ * session goes into its own clearly-labelled "New routes" group, and vendor
35
+ * filing happens when the server next renders the editor.
36
+ */
37
+
38
+ const { vendorOf } = require('../src/sidecar/fallback-chains');
39
+ const { PROVIDER_FAMILY_NAMES, listDirectProviders } = require('../src/utils/provider-registry');
40
+
41
+ /**
42
+ * Display names for vendors seen in alias routes.
43
+ *
44
+ * DISPLAY ONLY — deliberately not folded into provider-registry's PROVIDERS,
45
+ * which is a *capability* registry (env var, direct-vs-gateway, live fetch).
46
+ * KNOWN_PROVIDERS / PROVIDER_ENV_MAP are derived from that list, so adding
47
+ * `z-ai` there would claim Amicus can hold a z-ai API key. The five real
48
+ * providers keep their single source of truth via PROVIDER_FAMILY_NAMES.
49
+ */
50
+ const ALIAS_VENDOR_LABELS = {
51
+ ...PROVIDER_FAMILY_NAMES,
52
+ // Vendors reachable through the gateway (curated + commonly pinned)
53
+ 'qwen': 'Qwen',
54
+ 'mistralai': 'Mistral AI',
55
+ 'z-ai': 'Z.AI',
56
+ 'minimax': 'MiniMax',
57
+ 'x-ai': 'xAI',
58
+ 'moonshotai': 'Moonshot AI',
59
+ 'bytedance-seed': 'ByteDance Seed',
60
+ 'thinkingmachines': 'Thinking Machines',
61
+ 'cognitivecomputations': 'Cognitive Computations',
62
+ 'inclusionai': 'InclusionAI',
63
+ 'nvidia': 'NVIDIA',
64
+ 'cohere': 'Cohere',
65
+ 'meta-llama': 'Meta Llama',
66
+ 'nousresearch': 'Nous Research',
67
+ 'perplexity': 'Perplexity',
68
+ 'microsoft': 'Microsoft',
69
+ 'ai21': 'AI21',
70
+ 'amazon': 'Amazon',
71
+ // Local providers (src/utils/local-providers.js PRESETS / VALID_FLAVORS)
72
+ 'ollama': 'Ollama',
73
+ 'lmstudio': 'LM Studio',
74
+ 'vllm': 'vLLM',
75
+ };
76
+
77
+ /** `some-new-vendor` -> `Some New Vendor`, so an unmapped vendor is not a raw slug. */
78
+ function titleCaseVendor(vendor) {
79
+ return String(vendor).split(/[-_]/).filter(Boolean)
80
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
81
+ }
82
+
83
+ /**
84
+ * Vendor key for an alias route. Wraps the shared `vendorOf` with the two
85
+ * normalisations issue 213 flagged: case, and the leading `~` of a floating
86
+ * OpenRouter id (`openrouter/~z-ai/glm-latest` must not form a second group
87
+ * next to `z-ai`).
88
+ * @param {string} route @returns {string} '' when there is no usable route
89
+ */
90
+ function aliasVendorOf(route) {
91
+ const v = vendorOf(route).toLowerCase();
92
+ return v.charAt(0) === '~' ? v.slice(1) : v;
93
+ }
94
+
95
+ /**
96
+ * Display label for a vendor key.
97
+ * hasOwnProperty, not a bare lookup: vendor is derived from a user-editable
98
+ * route, and `__proto__`/`constructor` would otherwise return prototype junk.
99
+ * @param {string} vendor @returns {string}
100
+ */
101
+ function vendorLabel(vendor) {
102
+ if (!vendor) { return 'Other'; }
103
+ const hit = Object.prototype.hasOwnProperty.call(ALIAS_VENDOR_LABELS, vendor)
104
+ ? ALIAS_VENDOR_LABELS[vendor] : null;
105
+ return hit || titleCaseVendor(vendor);
106
+ }
107
+
108
+ /** Direct-route vendors render first; everything else sorts by label. */
109
+ const PREFERRED_VENDOR_ORDER = listDirectProviders();
110
+
111
+ /**
112
+ * Bucket an alias map by route vendor.
113
+ * INVARIANT: every own key of `aliases` lands in exactly one returned group —
114
+ * there is no whitelist to miss, and the empty vendor is a real catch-all.
115
+ * Order within a group follows the config's own key order.
116
+ * @param {Object<string,string>} aliases
117
+ * @returns {Array<{vendor: string, label: string, keys: string[]}>}
118
+ */
119
+ function groupAliases(aliases) {
120
+ const byVendor = new Map();
121
+ for (const key of Object.keys(aliases || {})) {
122
+ const vendor = aliasVendorOf(aliases[key]);
123
+ if (!byVendor.has(vendor)) { byVendor.set(vendor, []); }
124
+ byVendor.get(vendor).push(key);
125
+ }
126
+ const rank = (vendor) => {
127
+ if (!vendor) { return Number.MAX_SAFE_INTEGER; } // catch-all group last
128
+ const i = PREFERRED_VENDOR_ORDER.indexOf(vendor);
129
+ return i === -1 ? PREFERRED_VENDOR_ORDER.length : i;
130
+ };
131
+ return Array.from(byVendor.entries())
132
+ .map(([vendor, keys]) => ({ vendor, label: vendorLabel(vendor), keys }))
133
+ .sort((a, b) => rank(a.vendor) - rank(b.vendor) ||
134
+ a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
135
+ }
136
+
137
+ /**
138
+ * Heading for the client-side group that holds routes added during THIS
139
+ * wizard session. Exported so the inline script and the tests name the same
140
+ * string.
141
+ *
142
+ * Wording is deliberately non-committal about filing, but the reason is
143
+ * narrower than it once was. It used to be that Step 3 was built from
144
+ * getDefaultAliases(), so a custom alias had no row at all on reopen; that is
145
+ * fixed — electron/setup-ui.js now renders from the effective aliases, and a
146
+ * SAVED custom route is vendor-filed on the next open like any other.
147
+ *
148
+ * What the label still cannot promise is filing WITHIN this session: the page
149
+ * derives no vendors (issue 214 keeps routing policy server-side), so a route
150
+ * added here cannot move into its vendor group until the config round-trips.
151
+ * "this session" is exactly that scope.
152
+ */
153
+ const NEW_ROUTES_GROUP_LABEL = 'New routes (this session)';
154
+
155
+ module.exports = {
156
+ ALIAS_VENDOR_LABELS,
157
+ NEW_ROUTES_GROUP_LABEL,
158
+ aliasVendorOf,
159
+ vendorLabel,
160
+ groupAliases,
161
+ };
@@ -6,12 +6,21 @@
6
6
  * Extracted from setup-ui.js to keep file sizes under 300 lines.
7
7
  */
8
8
 
9
+ const { NEW_ROUTES_GROUP_LABEL } = require('./setup-ui-alias-groups');
10
+
9
11
  /**
10
12
  * Build the alias editor JS for inline inclusion in the wizard script
11
13
  * @returns {string} JavaScript source (no <script> tags)
12
14
  */
13
15
  function buildAliasScript() {
16
+ // This script runs in the wizard PAGE and cannot require(), so anything it
17
+ // shares with the Node builders is either serialised in as DATA (the group
18
+ // heading below) or not shared at all. The vendor grouping rule is the
19
+ // latter, on purpose: see the SHARED-WITH-THE-BROWSER note in
20
+ // setup-ui-alias-groups.js and the issue 214 guard in tests/setup-ui.test.js.
14
21
  return `
22
+ var NEW_ROUTES_GROUP_LABEL = ${JSON.stringify(NEW_ROUTES_GROUP_LABEL)};
23
+
15
24
  // Alias editor: search
16
25
  var aliasSearchInput = $('alias-search');
17
26
  if (aliasSearchInput) {
@@ -86,15 +95,68 @@ function buildAliasScript() {
86
95
  }
87
96
  });
88
97
  }
89
- // If current value not in options, add it
98
+ // issue 211: the current value is echoed back only because NOTHING in the
99
+ // catalog matched it -- it is not an offer. Rendered bare it read as the
100
+ // one first-class option Amicus recommends (a delisted id outranking 13
101
+ // real ones). Same string, honest framing: its own labelled optgroup.
90
102
  if (currentValue && !select.querySelector('option[value="' + CSS.escape(currentValue) + '"]')) {
103
+ var customGroup = document.createElement('optgroup');
104
+ customGroup.label = 'Current \\u2014 not found in catalog';
91
105
  var custom = document.createElement('option');
92
106
  custom.value = currentValue; custom.textContent = currentValue; custom.selected = true;
93
- select.insertBefore(custom, select.firstChild);
107
+ customGroup.appendChild(custom);
108
+ select.insertBefore(customGroup, select.firstChild);
94
109
  }
95
110
  return select;
96
111
  }
97
112
 
113
+ // issue 213: a new custom route used to be appended as an ungrouped sibling
114
+ // of every <details>, so it rendered below the last group with no heading at
115
+ // all. It now goes into its own clearly-labelled group. Vendor filing is the
116
+ // SERVER's job (setup-ui-alias-groups.js) -- deriving a vendor here would
117
+ // mean shipping gateway-prefix stripping back into the page, which is what
118
+ // issue 214 removed.
119
+ function placeRowInNewRoutesGroup(row) {
120
+ var editor = document.querySelector('.alias-editor');
121
+ if (!editor) { return; }
122
+ var group = editor.querySelector('.alias-group[data-new-routes]');
123
+ if (!group) {
124
+ group = document.createElement('details');
125
+ group.className = 'alias-group';
126
+ group.setAttribute('data-new-routes', '1');
127
+ var summary = document.createElement('summary');
128
+ var labelEl = document.createElement('span');
129
+ labelEl.textContent = NEW_ROUTES_GROUP_LABEL + ' ';
130
+ var countEl = document.createElement('span');
131
+ countEl.className = 'alias-count';
132
+ summary.appendChild(labelEl); summary.appendChild(countEl);
133
+ group.appendChild(summary);
134
+ editor.insertBefore(group, $('alias-add-btn'));
135
+ }
136
+ group.appendChild(row);
137
+ group.open = true;
138
+ refreshAliasCounts();
139
+ }
140
+
141
+ // Server-rendered counts are static; keep them true after add/remove/delete.
142
+ //
143
+ // Counts EXCLUDE .alias-deleted (council finding A3, PR 221). Deleting a
144
+ // server-rendered row marks it rather than removing it, so counting every
145
+ // .alias-row left the heading claiming rows the user had just struck out.
146
+ // groupAliases can never EMIT an empty group, but a server group can still be
147
+ // emptied here by deleting its last row -- it then honestly reads "(0)"
148
+ // rather than vanishing, because a struck-out row is still on screen and its
149
+ // deletion is not committed until Finish. Only the client-created new-routes
150
+ // group is dropped at zero: its rows are removed outright, so zero means gone.
151
+ function refreshAliasCounts() {
152
+ document.querySelectorAll('.alias-group').forEach(function(g) {
153
+ var rows = g.querySelectorAll('.alias-row:not(.alias-deleted)').length;
154
+ if (rows === 0 && g.hasAttribute('data-new-routes')) { g.remove(); return; }
155
+ var countEl = g.querySelector('.alias-count');
156
+ if (countEl) { countEl.textContent = '(' + rows + ')'; }
157
+ });
158
+ }
159
+
98
160
  // Alias editor: inline edit
99
161
  document.addEventListener('click', function(e) {
100
162
  var nameSpan = e.target.closest('.alias-name');
@@ -164,6 +226,10 @@ function buildAliasScript() {
164
226
  } else {
165
227
  delete aliasEdits[alias];
166
228
  }
229
+ // A3: this handler owns SERVER-rendered rows, whose group heading carries a
230
+ // count baked in at render time. Without this the heading kept counting a
231
+ // row the user had just struck out.
232
+ refreshAliasCounts();
167
233
  });
168
234
 
169
235
  // Alias editor: add custom shortcut
@@ -186,8 +252,7 @@ function buildAliasScript() {
186
252
  row.appendChild(arrow);
187
253
  row.appendChild(modelSelect);
188
254
  row.appendChild(delBtn);
189
- var editor = document.querySelector('.alias-editor');
190
- if (editor) { editor.insertBefore(row, addBtn); }
255
+ placeRowInNewRoutesGroup(row);
191
256
  nameInput.focus();
192
257
  function commitNew() {
193
258
  var n = nameInput.value.trim();
@@ -210,6 +275,7 @@ function buildAliasScript() {
210
275
  var a = row.getAttribute('data-alias');
211
276
  if (a) { delete aliasEdits[a]; }
212
277
  row.remove();
278
+ refreshAliasCounts();
213
279
  });
214
280
  });
215
281
  }`;
@@ -5,42 +5,46 @@
5
5
  * delete, and add functionality for the setup wizard Step 3.
6
6
  */
7
7
 
8
- /** Grouping metadata for the 21 default aliases */
9
- const ALIAS_GROUPS = [
10
- { name: 'Gemini', keys: ['gemini', 'gemini-pro'] },
11
- { name: 'GPT', keys: ['gpt', 'gpt-pro', 'codex'] },
12
- { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku', 'fable'] },
13
- { name: 'DeepSeek', keys: ['deepseek'] },
14
- { name: 'Qwen', keys: ['qwen', 'qwen-coder', 'qwen-flash'] },
15
- { name: 'Mistral', keys: ['mistral'] },
16
- { name: 'Other', keys: ['glm', 'minimax', 'grok', 'kimi', 'seed', 'inkling'] },
17
- ];
8
+ const { groupAliases } = require('./setup-ui-alias-groups');
9
+
10
+ /** Attribute/text-safe rendering of user-controlled alias names and routes. */
11
+ function esc(value) {
12
+ return String(value === undefined || value === null ? '' : value)
13
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
15
+ }
18
16
 
19
17
  /**
20
18
  * Build the HTML fragment for the alias editor section
19
+ *
20
+ * Issue 213: groups are derived from each alias's ROUTE VENDOR
21
+ * (setup-ui-alias-groups.js), not from a hardcoded list of alias names, so
22
+ * EVERY alias in `aliases` renders exactly once -- the old whitelist silently
23
+ * dropped any name it did not list (12 of 25 in a real config).
24
+ *
21
25
  * @param {Object<string,string>} aliases - Map of alias name to model string
22
26
  * @returns {string} HTML fragment with search, groups, rows, and add button
23
27
  */
24
28
  function buildAliasEditorHTML(aliases) {
25
29
  const searchInput = '<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">';
26
30
 
27
- const groups = ALIAS_GROUPS.map(group => {
31
+ const groups = groupAliases(aliases).map(group => {
28
32
  const rows = group.keys
29
- .filter(key => aliases[key] !== undefined)
30
33
  .map(key => {
31
34
  const model = aliases[key];
32
- return `<div class="alias-row" data-alias="${key}">` +
33
- `<span class="alias-name">${key}</span>` +
35
+ return `<div class="alias-row" data-alias="${esc(key)}">` +
36
+ `<span class="alias-name">${esc(key)}</span>` +
34
37
  '<span class="alias-arrow">\u2192</span>' +
35
- `<span class="alias-model">${model}</span>` +
36
- `<button class="alias-delete" data-alias="${key}">\u00d7</button>` +
38
+ `<span class="alias-model">${esc(model)}</span>` +
39
+ `<button class="alias-delete" data-alias="${esc(key)}">\u00d7</button>` +
37
40
  '</div>';
38
41
  }).join('\n ');
39
42
 
40
- const count = group.keys.filter(key => aliases[key] !== undefined).length;
41
-
42
- return `<details class="alias-group">
43
- <summary>${group.name} <span class="alias-count">(${count})</span></summary>
43
+ // data-vendor records WHICH vendor a group holds, for tests and for anyone
44
+ // inspecting the page. The client does not read it to place rows -- see the
45
+ // SHARED-WITH-THE-BROWSER note in setup-ui-alias-groups.js.
46
+ return `<details class="alias-group" data-vendor="${esc(group.vendor)}">
47
+ <summary>${esc(group.label)} <span class="alias-count">(${group.keys.length})</span></summary>
44
48
  ${rows}
45
49
  </details>`;
46
50
  }).join('\n ');
@@ -82,4 +86,4 @@ function buildAliasEditorHTML(aliases) {
82
86
  </div>`;
83
87
  }
84
88
 
85
- module.exports = { ALIAS_GROUPS, buildAliasEditorHTML };
89
+ module.exports = { buildAliasEditorHTML };
@@ -22,12 +22,22 @@ const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
22
22
  * @param {Object<string,object>} [options.shortlists] - issue 138: per-alias vendor
23
23
  * shortlist from buildModelShortlist(), passed through to buildModelStepHTML
24
24
  * for the model-level <select>. Defaults to {} (no drill-down rendered).
25
+ * @param {Object<string,string>} [options.aliases] - issue 213: the alias map Step 3
26
+ * renders. Callers pass getEffectiveAliases() (defaults MERGED with the user's
27
+ * config); the default here stays getDefaultAliases() so an omitted option is
28
+ * the old behaviour exactly. This is the second half of issue 213: fixing
29
+ * buildAliasEditorHTML's grouping guarantees "every alias passed in renders
30
+ * exactly once", but the app was only ever passing the 21 built-in defaults,
31
+ * so a user's custom aliases had no row at all. The config that arrives later
32
+ * over IPC cannot repair that -- applyAliasEditsToUI only rewrites the model
33
+ * text of rows that ALREADY exist (`if (!row) { return; }`).
25
34
  */
26
35
  function buildSetupHTML(options = {}) {
27
36
  const {
28
37
  client = 'code-local',
29
38
  quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
30
39
  shortlists = {},
40
+ aliases = getDefaultAliases(), // issue 213
31
41
  } = options;
32
42
  // Council A1 (PR 215): a pick reaching the page WITHOUT canonicalRoutes makes
33
43
  // pickRouteFor fall back to the raw openrouter/... route, which this codebase
@@ -42,7 +52,7 @@ function buildSetupHTML(options = {}) {
42
52
  const brandName = getBrandName(client);
43
53
  const keysHtml = buildKeysStepHTML(PROVIDERS);
44
54
  const modelHtml = buildModelStepHTML(picks, undefined, undefined, shortlists);
45
- const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
55
+ const aliasHtml = buildAliasEditorHTML(aliases);
46
56
  const css = buildWizardCSS();
47
57
  const providersJson = JSON.stringify(PROVIDERS);
48
58
  const modelChoicesJson = JSON.stringify(picks);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.2",
3
+ "version": "4.9.4",
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,6 +14,7 @@
14
14
  "count": { "type": "number" },
15
15
  "models": { "type": "array", "items": { "type": "object" } },
16
16
  "lastRefreshAttempt": { "type": ["number", "null"] },
17
- "lastRefreshError": { "type": ["string", "null"] }
17
+ "lastRefreshError": { "type": ["string", "null"] },
18
+ "ceilingEnrichment": { "type": ["object", "null"] }
18
19
  }
19
20
  }
@@ -24,6 +24,19 @@
24
24
  "usage": { "type": ["object", "null"] },
25
25
  "pack": { "type": "object" },
26
26
  "tag": { "type": "string" },
27
+ "finish": {
28
+ "type": "string",
29
+ "description": "#218 PR 3, optional. The engine's finish reason for the leg's last assistant message ('stop', 'length', 'tool-calls', …), copied verbatim from the assistant message's `finish`. EMIT-WHEN-SET like `ttftMs`: absent means the engine recorded none (the message never finalized, or an older engine). 'length' means the provider stopped at the max_tokens reservation — with answer text that is a cut review (announced as a Note on the `output-truncated` channel); without, the leg is status 'error' with an `OUTPUT_LENGTH:` reason."
30
+ },
31
+ "variant": {
32
+ "type": "string",
33
+ "description": "#218 PR 4, optional. The effort level amicus SENT as the engine's `variant` prompt field for this leg, after checking the engine's own declaration for the model (/config/providers). EMIT-WHEN-SENT: absent means no --thinking was requested, or the request was refused before anything was sent (then `status` is 'error' and `error` starts VARIANT_UNDECLARED or VARIANT_OVER_BUDGET — prefixed `Session setup failed: ` on an interactive `amicus start`). Not the engine's own `variant` echo, which it records even for a level it dropped (probe F3/M7). A leg whose no-output backstop fired before the prompt send resolved carries no `variant` even though one went out — the send result never arrived, so the record cannot claim it."
34
+ },
35
+ "variantUnverified": {
36
+ "type": "boolean",
37
+ "enum": [true],
38
+ "description": "#218 PR 4, optional, emit-when-true. The variant was sent to a model the engine's catalogue did not know within the wait (its row carries none of the cells a catalogue fills — no display name, family, release date, pricing or capabilities — only the descriptor amicus registered: a model newer than the engine's bundled catalogue before its startup refresh landed, or a custom/local model): the engine applied it only if it learned the model before building the request, and dropped it silently otherwise; amicus cannot tell which."
39
+ },
27
40
  "ttftMs": {
28
41
  "type": "integer",
29
42
  "minimum": 0,
@@ -236,14 +236,7 @@ amicus start \
236
236
  - `--context-turns <N>`: Max conversation turns to include (default: 50)
237
237
  - `--context-since <duration>`: Time filter for context (e.g., `2h`, `30m`, `1d`). Overrides `--context-turns`.
238
238
  - `--context-max-tokens <N>`: Max context size (default: 80000)
239
- - `--thinking <level>`: Model thinking/reasoning effort level:
240
- - `none` - No extended thinking
241
- - `minimal` - Minimal thinking (may be adjusted if unsupported by model)
242
- - `low` - Low thinking effort
243
- - `medium` - Medium thinking effort (default)
244
- - `high` - High thinking effort
245
- - `xhigh` - Extra high thinking effort
246
- Note: If the model doesn't support the specified level, it will be automatically adjusted.
239
+ - `--thinking <level>`: Reasoning effort — `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` (the levels the curated routes declare between them). Omit it for the provider's own default effort (nothing is sent then). A level the model does not declare is refused before anything is sent (`VARIANT_UNDECLARED`, naming the declared set); a declared level whose thinking budget would push the reservation over `outputBudget` on the direct Anthropic route is refused too (`VARIANT_OVER_BUDGET`); a model the engine's catalogue does not know in time is sent the level unverified (`variantUnverified: true` on the record). Nothing is adjusted.
247
240
  - `--summary-length <length>`: Summary verbosity:
248
241
  - `brief` - Concise summary
249
242
  - `normal` - Standard summary (default)
@@ -14,9 +14,12 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
14
14
  const localProvidersCheck = require('./utils/doctor-local-providers-check');
15
15
  // v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
16
16
  const baseUrlCheck = require('./utils/doctor-base-url-check');
17
+ const outputBudgetCheck = require('./utils/doctor-output-budget-check'); // #218 PR 2 — the 'output-budget' row.
17
18
  // B3 (council review of PR 198, issue 195) — the 'aliases' check body,
18
19
  // including its --fix repair of fabricated bare ids. Same split rationale.
19
20
  const aliasCheck = require('./utils/doctor-alias-check');
21
+ const creditCheck = require('./utils/doctor-credit-check');
22
+ const keyAuthCheck = require('./utils/doctor-key-auth-check'); // #210 — 'keys' tests presence only; this re-validates.
20
23
 
21
24
  const { DEFAULT_MAX_AGE_MS: MAX_CATALOG_AGE_MS } = require('./utils/model-catalog'); // 24h — single source
22
25
 
@@ -32,7 +35,8 @@ function realDeps() {
32
35
  nodeVersion: process.version,
33
36
  readApiKeys: () => require('./utils/api-key-store').readApiKeys(),
34
37
  readApiKeyValues: () => require('./utils/api-key-store').readApiKeyValues(),
35
- checkOpenRouterCredit: (key) => require('./utils/api-key-validation').checkOpenRouterCredit(key),
38
+ checkOpenRouterCredit: (key) => keyAuthCheck.probeOpenRouterCredit(key), // #210 — same gate as validateApiKey
39
+ validateApiKey: (p, k) => keyAuthCheck.probeApiKey(p, k), // #210
36
40
  getCwd: () => process.cwd(),
37
41
  readProjectMarkers: (dir) => {
38
42
  const exists = (name) => { try { return fs.existsSync(path.join(dir, name)); } catch (_e) { return false; } };
@@ -42,6 +46,7 @@ function realDeps() {
42
46
  resolveModel: () => require('./utils/config').resolveModel(),
43
47
  readCache: () => require('./utils/model-catalog').readCache(),
44
48
  collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
49
+ readOutputBudgetRaw: () => (require('./utils/config').loadConfig() || {}).outputBudget, // #218 PR 2: as stored, so a malformed value is echoed
45
50
  findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
46
51
  findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
47
52
  // B3: the narrow fabricated-bare-id repair class (pure detection) + the
@@ -141,6 +146,7 @@ async function runDoctorChecks(depsOverride = {}) {
141
146
  : { id: 'keys', name: 'API keys', status: 'error', message: 'no provider keys configured', hint: 'amicus key <provider> <key> (or run: amicus setup)' };
142
147
  }));
143
148
 
149
+ checks.push(await guardAsync('key-auth', 'API key auth', () => keyAuthCheck.evaluateKeyAuth(d))); // #210
144
150
  checks.push((() => {
145
151
  try {
146
152
  const model = d.resolveModel();
@@ -167,6 +173,7 @@ async function runDoctorChecks(depsOverride = {}) {
167
173
  // utils/doctor-alias-check.js for the check body and utils/alias-audit.js's
168
174
  // findFabricatedAliasRepairs for the detection rule.
169
175
  checks.push(guard('aliases', 'Model aliases', () => aliasCheck.evaluateAliasesCheck(d)));
176
+ checks.push(guard('output-budget', 'Output budget', () => outputBudgetCheck.evaluateOutputBudget(d))); // #218 PR 2
170
177
 
171
178
  checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
172
179
  () => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
@@ -211,21 +218,10 @@ async function runDoctorChecks(depsOverride = {}) {
211
218
 
212
219
  checks.push(guard('session-metadata-tmp', 'Session metadata tmp files', () => metaSweep.evaluateSessionMetadataTmpSweep(d)));
213
220
 
214
- // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
215
- checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
216
- const values = d.readApiKeyValues() || {};
217
- const key = values.openrouter;
218
- if (!key) {
219
- return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: 'no OpenRouter key — skipped', hint: null };
220
- }
221
- // Reuses the #38 non-blocking probe; resolves warning:null on any failure.
222
- const res = (await d.checkOpenRouterCredit(key)) || {};
223
- if (res.warning) {
224
- return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'warn', message: res.warning, hint: 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).' };
225
- }
226
- const remaining = (typeof res.limitRemaining === 'number') ? ` ($${res.limitRemaining} remaining)` : '';
227
- return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: `credit ok${remaining}`, hint: null };
228
- }));
221
+ // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no
222
+ // key. Body in utils/doctor-credit-check.js (same split as the others).
223
+ checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit',
224
+ () => creditCheck.evaluateOpenRouterCredit(d)));
229
225
 
230
226
  // v4.2 §4.7 C8: configured local / OpenAI-compatible providers (Ollama, LM
231
227
  // Studio, vLLM, generic) — reachability only; warn, never error (a napping
@@ -8,7 +8,7 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- const { validateTaskId, validateTag } = require('./utils/validators');
11
+ const { validateTaskId, validateTag, validateThinkingLevel } = require('./utils/validators');
12
12
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
13
13
  const { GATEWAY_MODES } = require('./utils/model-descriptor');
14
14
  const { applyTemplateForArgs } = require('./cli-template-args');
@@ -54,6 +54,15 @@ async function handleFanout(args) {
54
54
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` }));
55
55
  }
56
56
 
57
+ // #218 PR 4 whole-branch review (VCMD-2): the same vocabulary check `start` runs in
58
+ // validateStartArgs (cli.js) — fanout never did, so a typo (or a pack's) reached sendPrompt:
59
+ // sent unverified to an unknown model, a per-leg VARIANT_UNDECLARED after the spawn on a
60
+ // known one. Named mutant "FANOUTTHINKINGUNCHECKED" (tests/fanout-cli.test.js): drop this block.
61
+ const thinkingCheck = validateThinkingLevel(args.thinking);
62
+ if (!thinkingCheck.valid) {
63
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: thinkingCheck.error }));
64
+ }
65
+
57
66
  const { resolvePromptSource } = require('./utils/prompt-source');
58
67
  let promptRes;
59
68
  if (args.prompt !== undefined || args['prompt-file'] !== undefined || args.template === undefined) {
@@ -32,6 +32,21 @@ async function handleResume(args) {
32
32
  message: 'Error: --tag is not supported on resume — the tag is inherited from the parent session',
33
33
  }));
34
34
  }
35
+ // #218 PR 4, council #235 r1 (D6): the same silently-ignored-flag shape as --tag
36
+ // above. `--thinking` parses on every command because getKnownFlags()
37
+ // (utils/known-flags.js) scrapes the whole usage string and start's block declares
38
+ // it (cli.js), this handler never reads it, and validateStartArgs — PR 4's
39
+ // vocabulary check — runs only on `start` (cli-handlers-run.js), so even
40
+ // `--thinking bogus` used to exit 0 here having done nothing. Before PR 4 the level
41
+ // reached nothing on any path (probe F1) and the commands behaved alike by accident;
42
+ // it is the engine's `variant` on start/fanout now. Named mutant
43
+ // "RESUMETHINKINGSILENT" (tests/cli-handlers-resume-continue.test.js): drop this guard.
44
+ if (args.thinking !== undefined) {
45
+ process.exit(failJson(useJson, {
46
+ code: ERROR_CODES.BAD_ARGS,
47
+ message: 'Error: --thinking is not supported on resume — the level belongs to the run that started the session, and resume reopens that session',
48
+ }));
49
+ }
35
50
  const taskId = requireValidTaskId(args, useJson, 'resume', 'Usage: amicus resume <task_id>');
36
51
  requireNoUiForJson(args, useJson);
37
52
 
@@ -71,6 +86,16 @@ async function handleContinue(args) {
71
86
  message: 'Error: --tag is not supported on continue — the tag is inherited from the parent session',
72
87
  }));
73
88
  }
89
+ // #218 PR 4, council #235 r1 (D6): same rationale as handleResume above — the flag
90
+ // parses here, is read by nobody, and is not even vocabulary-checked. A continuation
91
+ // is a NEW session, so a level for it belongs on the `start` that opens one. Named
92
+ // mutant "CONTINUETHINKINGSILENT" (tests/cli-handlers-resume-continue.test.js).
93
+ if (args.thinking !== undefined) {
94
+ process.exit(failJson(useJson, {
95
+ code: ERROR_CODES.BAD_ARGS,
96
+ message: 'Error: --thinking is not supported on continue — the level belongs to the run that starts a session; use `amicus start --thinking <level>` to open one with a level',
97
+ }));
98
+ }
74
99
  const taskId = requireValidTaskId(args, useJson, 'continue', 'Usage: amicus continue <task_id> --prompt "..."');
75
100
 
76
101
  // BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
@@ -176,7 +176,23 @@ async function handleKey(args) {
176
176
 
177
177
  console.log(`Validating ${provider} key...`);
178
178
  const validation = await validateApiKey(provider, keyArg);
179
- if (!validation.valid) {
179
+ // 401 is the only status that means "this credential is not accepted".
180
+ // Everything else — 403 (disabled API, quota, region/bot block), 429, any
181
+ // 5xx, a 404 from a moved endpoint, a Cloudflare 52x during an origin
182
+ // outage, or no status at all because the machine is offline — says
183
+ // something about the REQUEST, not the key. Refusing to save on those is
184
+ // the false ALARM the doctor classifier stopped raising.
185
+ //
186
+ // ⚠️ An ALLOWLIST of what blocks, deliberately. This was a blocklist of
187
+ // what does NOT block, which left every unenumerated status falling through
188
+ // to process.exit(1) while the comment above it claimed only 401 blocked —
189
+ // the code and the narrative disagreed, and the narrative was the nicer of
190
+ // the two. An allowlist cannot rot as new status codes appear.
191
+ const BLOCKS_SAVE = new Set([401]);
192
+ if (!validation.valid && !BLOCKS_SAVE.has(validation.status)) {
193
+ console.warn(`Warning: ${validation.error}`);
194
+ console.warn('Saving the key anyway — run `amicus doctor` to re-check it later.');
195
+ } else if (!validation.valid) {
180
196
  console.error(`Error: ${validation.error}`);
181
197
  process.exit(1);
182
198
  }
package/src/cli.js CHANGED
@@ -326,16 +326,13 @@ function validateStartArgs(args) {
326
326
  return { valid: false, error: `Error: --summary-length must be one of: ${validSummaryLengths.join(', ')}` };
327
327
  }
328
328
 
329
- // Validate thinking effort level (if provided), with model-specific support check
330
- const thinkingCheck = validateThinkingLevel(args.thinking, args.model);
329
+ // Validate the thinking level's VOCABULARY (#218 PR 4). Whether the model
330
+ // declares it is checked against the engine's own catalogue at send time
331
+ // (opencode-client.js :: sendPrompt); nothing is adjusted here any more.
332
+ const thinkingCheck = validateThinkingLevel(args.thinking);
331
333
  if (!thinkingCheck.valid) {
332
334
  return thinkingCheck;
333
335
  }
334
- // If model doesn't support the level, adjust it and warn
335
- if (thinkingCheck.warning) {
336
- logger.warn('Thinking level adjusted', { warning: thinkingCheck.warning, adjustedLevel: thinkingCheck.adjustedLevel });
337
- args.thinking = thinkingCheck.adjustedLevel;
338
- }
339
336
 
340
337
  // Validate API key is present for the model's provider
341
338
  const apiKeyCheck = validateApiKey(args.model);
@@ -444,7 +441,7 @@ Options for 'start':
444
441
  --context-since <duration> Time filter (e.g., 2h). Overrides turns.
445
442
  --context-max-tokens <N> Max context tokens (default: 80000)
446
443
  --summary-length <length> Summary verbosity: brief, normal (default), verbose
447
- --thinking <level> Reasoning effort: none, minimal, low, medium, high, xhigh
444
+ --thinking <level> Reasoning effort: none, minimal, low, medium, high, xhigh, max (omit for the provider's default)
448
445
  --mcp <spec> Add MCP server. Formats:
449
446
  - name=url (remote server)
450
447
  - name=command (local server)