amicus 4.9.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +139 -0
- package/README.md +1 -1
- package/bin/amicus.js +6 -0
- package/docs/configuration.md +52 -0
- package/docs/usage.md +4 -1
- package/electron/main.js +25 -2
- package/electron/setup-ui-alias-groups.js +161 -0
- package/electron/setup-ui-alias-script.js +70 -4
- package/electron/setup-ui-aliases.js +25 -21
- package/electron/setup-ui.js +11 -1
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +10 -0
- package/src/cli-handlers-doctor.js +9 -16
- package/src/cli-handlers.js +17 -1
- package/src/council/run-retry-window.js +62 -0
- package/src/council/run-retry.js +7 -10
- package/src/council/run-stage2.js +47 -3
- package/src/council/tally.js +12 -0
- package/src/council/verdict-seats-reviewed.js +60 -0
- package/src/council/verdict.js +23 -0
- package/src/headless.js +90 -9
- package/src/utils/api-key-validation.js +183 -94
- package/src/utils/config.js +43 -1
- package/src/utils/degrade.js +8 -0
- package/src/utils/doctor-credit-check.js +61 -0
- package/src/utils/doctor-key-auth-check.js +271 -0
- package/src/utils/live-probes.js +53 -0
- package/src/utils/model-fetcher.js +2 -0
- package/src/utils/model-output-limit.js +124 -0
- package/src/utils/openrouter-credit.js +104 -0
- package/src/utils/session-status.js +73 -0
- package/src/utils/ttft.js +17 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
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,145 @@
|
|
|
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
|
+
|
|
88
|
+
## [4.9.2] - 2026-08-27
|
|
89
|
+
|
|
90
|
+
*The instrument existed; nothing could read it.*
|
|
91
|
+
|
|
92
|
+
Issue #202 deferred its retry-policy decision to evidence: "v4.9 W13 records per-leg
|
|
93
|
+
time-to-first-token in `runStats`, so the next rev can derive this from observation instead of
|
|
94
|
+
argument." That probe had never reported a value into any artifact CI uploads — `tally.js`'s
|
|
95
|
+
hand-maintained allowlist stripped it one hop before `tally.json`. Reading it changed the
|
|
96
|
+
diagnosis: first tokens on the CI egress are a continuous heavy tail (8.0 s to 384.2 s, no gap),
|
|
97
|
+
not an upstream that accepts and never serves. This release fixes the instrument, then the
|
|
98
|
+
kill switches that were set inside that tail.
|
|
99
|
+
|
|
100
|
+
### Fixed
|
|
101
|
+
|
|
102
|
+
- **`ttftMs` survives the runStats re-projection (#202).** `tally.js :: tally` re-projects every
|
|
103
|
+
row through a hand-maintained allowlist that never named the field, and `verdict.js` copies that
|
|
104
|
+
array verbatim — so the W13 probe emitted correctly into `tally-input.json` and was destroyed
|
|
105
|
+
before `tally.json` and `verdict.json`, the only artifacts CI uploads. MEASURED, run
|
|
106
|
+
33030485388: 11 of 12 rows carried it going in, 0 of 12 coming out. `tally.js` becomes the fifth
|
|
107
|
+
emit gate and the fourth importer of the shared `isMeasuredTtft` predicate. A drift pin now fails
|
|
108
|
+
for ANY future `buildRunStatsEntry` key the allowlist is not taught to carry.
|
|
109
|
+
- **A zero-output leg now names its cause (#202).** `getSessionStatus` was called only inside
|
|
110
|
+
`if (mirror.output.length > 0)` — a gate a leg that produced nothing never satisfies — so the one
|
|
111
|
+
leg needing diagnosis was the only one that never asked. A bounded, non-throwing read now runs at
|
|
112
|
+
the two backstop firing sites (a living leg makes no extra call) and appends `busy` /`idle`/
|
|
113
|
+
`retry` with the upstream message. `busy` means provider-side, `idle` means engine-side, `retry`
|
|
114
|
+
names the cause; none is suppressed. Untrusted provider text is sanitized.
|
|
115
|
+
- **Dead Stage-2 judge legs are announced (#202).** A dead judge leg still binds to its seat, so it
|
|
116
|
+
was neither `orphan` nor `unbound` and Stage 2 had no third case: it fell through into
|
|
117
|
+
`judgeResults` unremarked. Run 32956900910 shipped a four-column adjudication matrix that two of
|
|
118
|
+
its four judges never voted in, with no degrade recorded. New `stage2-judge` channel.
|
|
119
|
+
- **B53 no longer kills healthy, billing legs.** `TOOL_CALL_STALL_MS` was 180 s, condemned by this
|
|
120
|
+
repo's own measurement — a real 190.6 s `task` call recorded in `headless.js`, taken on a
|
|
121
|
+
developer machine. That measurement had corrected its neighbour (the settle deferral) and left
|
|
122
|
+
its own subject alone. Now 300 s, with CI overriding to 480 s.
|
|
123
|
+
- **The Stage-1 retry backstop no longer ties with the leg timeout.** `min(2 * backstop, legCap)`
|
|
124
|
+
made the deadlines equal whenever `2 * backstop >= legCap`; the backstop won only by the poll
|
|
125
|
+
loop's ordering. It now clamps strictly below, so a retry death keeps its named diagnosis
|
|
126
|
+
instead of degrading to a generic `timeout`.
|
|
127
|
+
|
|
128
|
+
### Added
|
|
129
|
+
|
|
130
|
+
- **`verdict.json` publishes `seatsReviewed {reviewed, of}`.** `deriveSeatLoss` returns null when
|
|
131
|
+
no `--critic` was requested, and CI requests none, so seat loss was structurally absent from
|
|
132
|
+
every CI verdict while a two-seat bench published a four-model street-cred table. Derived from
|
|
133
|
+
`runStats`, counting the bench roles `buildSeats` mints (`seat`, `critic`, `lens:<slug>`).
|
|
134
|
+
Surfaced in the check-run title and the sticky comment footer.
|
|
135
|
+
|
|
136
|
+
### Changed
|
|
137
|
+
|
|
138
|
+
- **A dead Stage-2 judge now degrades the run, so it exits 2.** Previously a half-adjudicated
|
|
139
|
+
verdict could exit 0. This changes CI signal: runs that passed before will now report degraded
|
|
140
|
+
when a judge dies.
|
|
141
|
+
- **CI council caps.** Per-leg `--timeout` 10 -> 16 min and `timeout-minutes` 45 -> 75, to give the
|
|
142
|
+
tool-stall detector real reach (120 s -> 480 s of a leg). The job cap covers a worst case of
|
|
143
|
+
four leg caps; that figure is a floor, since Stage-2 repairs are serial.
|
|
144
|
+
|
|
6
145
|
## [4.9.1] - 2026-08-27
|
|
7
146
|
|
|
8
147
|
*A silent provider failure, and the unservable model ids it produced.*
|
package/README.md
CHANGED
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');
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}`;
|