amicus 4.9.2 → 4.9.3

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": "4.9.2",
3
+ "version": "4.9.3",
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
@@ -3,6 +3,88 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [4.9.3] - 2026-08-28
7
+
8
+ *Doctor stops vouching for things it never checked.*
9
+
10
+ `doctor`'s `keys` row tested PRESENCE only, and `validateApiKey` was called at exactly two
11
+ save-time sites — so a key that rotted after it was entered was never re-checked. On the
12
+ reporting machine `doctor` printed a green row while the stored DeepSeek key returned 401 and
13
+ the catalog served zero deepseek rows. Closing that gap surfaced a family of the same shape:
14
+ several places reported health they had not established, and two of them were introduced by
15
+ the fixes for the others. Every one is now the same rule — a check that did not complete says
16
+ so, and only a definitive 401 is a verdict about a credential.
17
+
18
+ ### Added
19
+
20
+ - **`outputBudget` (#218), opt-in, no default change.** Each council leg reserved
21
+ `max_tokens: 32000` regardless of the model's real ceiling, and OpenRouter validates that
22
+ RESERVATION against remaining credit *before* serving — so legs died in 2.2 s with zero
23
+ tokens and "You requested up to 32000 tokens, but can only afford 354". Set `outputBudget`
24
+ in `config.json` and each leg reserves `min(budget, that model's real ceiling)`; leave it
25
+ unset and every model is registered exactly as before. A new `maxOutputTokens` catalog field
26
+ (OpenRouter's `top_provider.max_completion_tokens`, present on 411 of 417 rows) supplies the
27
+ ceiling, and a model without one keeps the old behaviour rather than receiving a guess.
28
+ MEASURED in the pinned engine binary: `maxOutputTokens = Math.min(limit.output, 32000)`, so
29
+ this can only LOWER a reservation — a value at or above 32000 leaves it unchanged. It does
30
+ not address a reasoning-heavy leg spending its whole allowance and emitting nothing; that is
31
+ governed by reasoning effort, not by `max_tokens`, and no claim is made otherwise.
32
+
33
+ ### Fixed
34
+
35
+ - **`doctor` re-validates stored API keys (#210).** New `key-auth` row probes every stored key
36
+ against its provider's own endpoint, in parallel — sequential 10 s timeouts would have added
37
+ ~50 s to every run. Only HTTP 401 fails the check; a timeout, DNS failure, 5xx or 429 warns,
38
+ because being offline is not a rotted key and a false error sends someone to re-enter a
39
+ working one. A stored key for a provider with no validation endpoint warns rather than
40
+ reporting ok — it cannot be probed, so the check cannot vouch for it.
41
+ - **403 is no longer treated as a credential verdict.** Google returns 403 for "API not
42
+ enabled" and for quota; a WAF returns it for bot protection. It warns now, and `amicus key`
43
+ saves on it rather than refusing — as it does for 429 and 5xx. Only a definitive 401 blocks
44
+ a save, expressed as an allowlist so it cannot rot as new status codes appear.
45
+ - **The OpenRouter credit row means CHECKED.** `checkOpenRouterCredit` resolves `warning: null`
46
+ for a healthy account, for a skipped probe, and for every failure alike — so the row rendered
47
+ "credit ok" for an account nobody had reached, concealing quota exhaustion behind a green
48
+ line. It now distinguishes all three.
49
+ - **A key can no longer escape in an error message.** `https.get` can throw synchronously, and
50
+ the Google probe embeds the key in the URL as `?key=…` — so an error quoting that URL quoted
51
+ the key. Redaction happens at the source now, covering the raw, percent-encoded and
52
+ form-encoded spellings, which protects the two save-time call sites that have no handling of
53
+ their own: `electron/ipc-setup.js` returns the message to the renderer *and* logs it, and
54
+ `src/cli-handlers.js` awaits with no try/catch at all.
55
+ - **`validateApiKey` honours its "always resolves" contract (#224).** `req.on('error')` covered
56
+ the connection phase only; an error once the response existed — a socket reset mid-body — was
57
+ an unhandled `'error'` event, which Node turns into a THROW rather than a rejection: the
58
+ promise never settled and the process died. Both functions in the module handle it now, and
59
+ the message coercion itself can no longer throw for a null-prototype object or one whose
60
+ `toString` throws.
61
+ - **Diagnostics no longer make live authenticated requests outside the CLI.** Probes are opt-in,
62
+ enabled once by `bin/amicus.js`; a skipped probe is reported as unverified, never as healthy.
63
+ `AMICUS_NO_NETWORK_PROBES=1` forces them off.
64
+ - **The setup wizard renders the aliases you actually have (#213).** The alias editor grouped
65
+ rows from a hardcoded list of alias NAMES and iterated that whitelist rather than your
66
+ aliases, so any alias whose name missed the list rendered nowhere — including the `lmstudio`
67
+ local-provider alias and every `free-*` alias the `councils.free` preset references. Grouping
68
+ derives from the alias's route vendor now. Measured against a real 33-alias config: 21
69
+ rendered before, 33 after, none dropped, none duplicated.
70
+ - **A stale pin no longer looks like a recommendation (#211).** When nothing in the catalog
71
+ matched an alias's current value, the dropdown echoed that value back as a bare ungrouped
72
+ option — indistinguishable from a real offer, and observed presenting an id that exists on no
73
+ gateway above 13 genuine ones. It now sits in a labelled "Current — not found in catalog"
74
+ group. What gets saved is unchanged.
75
+ - **Alias names and routes are HTML-escaped** in the wizard; a quote in an alias name broke the
76
+ row's `data-alias` attribute.
77
+
78
+ ### Changed
79
+
80
+ - **CI: the macOS/node-24 jest-worker `SIGSEGV` mitigation switches levers.** A fifth
81
+ occurrence landed with the 512 MB idle ceiling in force, so per the rule recorded beside it
82
+ the lever changes rather than the number: `--maxWorkers=1` now caps concurrent worker heaps.
83
+ A "Runner capacity" step reports cpus/mem on every leg, which established what five previous
84
+ hit records had assumed — macOS runners have 3 vCPU and 8 GB against 4 vCPU and 17 GB
85
+ elsewhere, so `--maxWorkers=2` would have been the default spelled out and changed nothing.
86
+ That leg runs ~4m → 6.5m as a result.
87
+
6
88
  ## [4.9.2] - 2026-08-27
7
89
 
8
90
  *The instrument existed; nothing could read it.*
package/README.md CHANGED
@@ -455,7 +455,7 @@ $ amicus status demo123 --json
455
455
  "taskId": "demo123",
456
456
  "status": "complete",
457
457
  "elapsed": "5m 0s",
458
- "version": "4.9.2",
458
+ "version": "4.9.3",
459
459
  "model": "google/gemini-2.5-flash",
460
460
  "phase": "terminal"
461
461
  }
package/bin/amicus.js CHANGED
@@ -16,6 +16,12 @@ if (!_nv.ok) { process.stderr.write(_nv.message + '\n'); process.exit(1); }
16
16
  const { loadCredentials } = require('../src/utils/env-loader');
17
17
  loadCredentials();
18
18
 
19
+ // Diagnostics probe live provider endpoints with those keys. That is allowed
20
+ // HERE and nowhere else: utils/live-probes.js defaults to off, so a module
21
+ // required outside this CLI (a test, a script) can never spend them. A skipped
22
+ // probe is reported as unverified, never as healthy — see live-probes.js.
23
+ require('../src/utils/live-probes').enableLiveProbes();
24
+
19
25
  const { parseArgs, getUsage, getCommandNames } = require('../src/cli');
20
26
  const { handleSetup, handleAbort, handleUpdate, handleMcp, handleKey } = require('../src/cli-handlers');
21
27
  const { handleStart, handleFanout, handleRead } = require('../src/cli-handlers-run');
@@ -49,6 +49,58 @@ for the pre-normalization failure mode and the manual fix if you've disabled the
49
49
 
50
50
  ---
51
51
 
52
+ ## Output budget (`outputBudget`)
53
+
54
+ Each council leg reserves a `max_tokens` allowance before the model runs. Amicus previously handed
55
+ OpenCode no per-model limit at all, so OpenCode's own fixed default — **32,000** — governed every
56
+ leg regardless of the model's real ceiling.
57
+
58
+ That reservation is not free. OpenRouter validates it against your remaining credit *before* serving,
59
+ so a leg that would have emitted 800 tokens gets refused outright for asking to reserve 32,000:
60
+
61
+ ```
62
+ This request requires more credits, or fewer max_tokens.
63
+ You requested up to 32000 tokens, but can only afford 354
64
+ ```
65
+
66
+ `outputBudget` lowers the reservation. Each model reserves `min(outputBudget, that model's real
67
+ ceiling)`, so a small model keeps its own lower limit rather than being handed an over-ceiling value.
68
+
69
+ | Setting | Values | Default | Effect |
70
+ |---------|--------|---------|--------|
71
+ | `outputBudget` (config.json, top-level) | positive integer | *unset* | Per-leg output reservation, clamped to each model's real ceiling. Unset means no limit is sent — OpenCode's 32,000 default applies, exactly as before. |
72
+
73
+ Set it by hand-editing `~/.config/amicus/config.json`:
74
+
75
+ ```json
76
+ { "outputBudget": 8000 }
77
+ ```
78
+
79
+ Two limits worth knowing before you set it:
80
+
81
+ - **It can only lower the reservation, never raise it.** OpenCode computes
82
+ `Math.min(limit.output, 32000)`, so any value at or above 32,000 leaves the reservation itself
83
+ unchanged. Raising the ceiling past 32,000 needs OpenCode's own
84
+ `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` environment variable — a different lever that Amicus does
85
+ not set.
86
+
87
+ Setting a value ≥ 32,000 is **not** a complete no-op, though. Amicus still emits the `limit`
88
+ descriptor, which carries the model's context length alongside the output figure — and OpenCode
89
+ disables prompt compaction for any model whose context reads as `0`, which is what a model it does
90
+ not recognise otherwise gets. So a high budget leaves `max_tokens` alone while still restoring
91
+ compaction for those models. If you want neither effect, leave `outputBudget` unset.
92
+ - **It needs a catalog that knows each model's ceiling.** Run `amicus models --refresh` after setting
93
+ it. Models whose ceiling is unknown — anything fetched before this field existed, and the direct
94
+ `openai` / `anthropic` / `google` / `deepseek` lists, which don't publish one — keep the old
95
+ behaviour rather than receiving a guessed limit.
96
+
97
+ This addresses reservation *rejections*. It does **not** stop a reasoning-heavy model from spending
98
+ its whole allowance on reasoning and emitting nothing — that is governed by reasoning effort
99
+ (`--thinking`), not by `max_tokens`. Lowering the budget makes such a leg fail faster and cheaper; it
100
+ does not make it produce output.
101
+
102
+ ---
103
+
52
104
  ## Routing
53
105
 
54
106
  `routing.prefer` in `config.json` sets the global default gateway policy; `--gateway` (CLI) or the
package/docs/usage.md CHANGED
@@ -538,7 +538,7 @@ $ amicus status demo123 --json
538
538
  "taskId": "demo123",
539
539
  "status": "complete",
540
540
  "elapsed": "5m 0s",
541
- "version": "4.9.2",
541
+ "version": "4.9.3",
542
542
  "model": "google/gemini-2.5-flash",
543
543
  "phase": "terminal"
544
544
  }
@@ -593,6 +593,7 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
593
593
  | `node` | Node.js ≥ 22.12 | error |
594
594
  | `config-dir` | The resolved config directory | *(always ok)* |
595
595
  | `keys` | At least one cloud-vendor key configured | error |
596
+ | `key-auth` **(#210)** | Every stored key is still accepted by its provider (skipped — reports `ok` — when no keys are stored) | error on a 401/403, warn when the probe can't reach the provider |
596
597
  | `default-model` | Your default model alias resolves | error |
597
598
  | `catalog` | Model-catalog cache present and within the 24h TTL | warn |
598
599
  | `aliases` | Your configured aliases still resolve against the catalog | warn |
@@ -609,6 +610,8 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
609
610
  | `local-providers` **(v4.2)** | Every provider in `config.providers` is reachable | warn |
610
611
  | `project-root` | Your cwd looks like a real project, not an app/install dir | warn |
611
612
 
613
+ **`key-auth`** re-validates stored keys against each provider's own endpoint, because `keys` above tests **presence** only — a key that rots *after* it was entered was previously never re-checked, so `doctor` could report ✓ while a provider returned 401 and the catalog silently served zero rows for it. All stored keys are probed in parallel (sequential 10s timeouts would add up to ~50s to every run). Only a **401/403** is treated as a definitive rejection and fails the check; a timeout, DNS/socket failure, 5xx or unexpected status is reported as `warn` — being offline is not a rotted key, and a false error would send you to re-enter a perfectly good one. Only provider names and a sanitized reason are ever printed; no key material or request URL reaches the output or the `--json` artifact.
614
+
612
615
  **`local-providers`** probes every configured local provider (2s timeout each) the same way `amicus provider test` does, and reports per-id reachability in one line, e.g. `ollama: 3 models @ http://127.0.0.1:11434/v1; my-vllm: unreachable @ http://127.0.0.1:8000/v1`. No providers configured at all is a plain `ok` ("none configured") — this check can never fail your doctor run outright, only warn: a napping `ollama serve` isn't treated as broken setup.
613
616
 
614
617
  `--fix` self-heals five of the checks above in place: reprovisions Electron, copies the OpenCode engine into a broken npx-cache install, removes a duplicate legacy MCP entry, sweeps orphaned session-index tmp files, and sweeps orphaned per-session metadata tmp files (both tmp sweeps only ones older than 60s). It does **not** start a local server for you — `local-providers` stays a warning until you start the server yourself.
package/electron/main.js CHANGED
@@ -370,7 +370,19 @@ async function createSetupWindow() {
370
370
  }
371
371
  });
372
372
 
373
- const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
373
+ // issue 213: render Step 3 from the user's EFFECTIVE aliases (defaults merged
374
+ // with config), not the 21 built-in defaults. Custom aliases used to get no
375
+ // row at all -- and the config arriving later over IPC could not add one,
376
+ // since applyAliasEditsToUI only rewrites rows that already exist. Its own
377
+ // try/catch: an unreadable config must cost the alias list, not the window.
378
+ let aliases;
379
+ try {
380
+ aliases = require('../src/utils/config').getEffectiveAliases();
381
+ } catch (_err) {
382
+ aliases = undefined; // buildSetupHTML falls back to the defaults
383
+ }
384
+
385
+ const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists, aliases });
374
386
  mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
375
387
  mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
376
388
 
@@ -569,7 +581,18 @@ function createSettingsChildWindow() {
569
581
  }
570
582
  });
571
583
 
572
- const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
584
+ // issue 213: mirror createSetupWindow -- Step 3 renders the user's EFFECTIVE
585
+ // aliases, not just the built-in defaults. getEffectiveAliases() reads the
586
+ // config file synchronously (loadConfig), so this keeps the "Settings must
587
+ // never trigger a network fetch / stay synchronous" property intact.
588
+ let aliases;
589
+ try {
590
+ aliases = require('../src/utils/config').getEffectiveAliases();
591
+ } catch (_err) {
592
+ aliases = undefined; // buildSetupHTML falls back to the defaults
593
+ }
594
+
595
+ const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists, aliases });
573
596
  settingsWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
574
597
  settingsWin.webContents.on('page-title-updated', (e) => e.preventDefault());
575
598
  }
@@ -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.3",
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": [