amicus 4.1.0 → 4.1.2

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.1.0",
3
+ "version": "4.1.2",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,119 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.1.2] - 2026-07-22
9
+
10
+ ### Fixed
11
+
12
+ - **Claude model aliases no longer drift between the direct API and OpenRouter.** The two
13
+ gateways spell the same model differently — OpenRouter serves `anthropic/claude-opus-4.8`,
14
+ the direct Anthropic API serves `anthropic/claude-opus-4-8` — and three places converted
15
+ between the two by adding or removing the `openrouter/` prefix. That is only sound when the
16
+ rest of the id is identical, which for Claude it is not. Two user-visible consequences: a fresh
17
+ `amicus setup` wrote an `opus` alias that `amicus doctor` then reported as stale (the exact
18
+ warning 4.1.1 shipped to remove — and it fired even with an Anthropic-only catalog, not just
19
+ for OpenRouter users), and the pre-registered fallback catalog handed to a long-lived shared
20
+ server carried two ids OpenRouter does not serve while omitting the two it does. All three
21
+ sites now read each gateway's authored route instead of deriving one from the other, and an
22
+ alias you have overridden yourself is left alone rather than inheriting a curated route.
23
+ Affects `opus` and `haiku`; every other alias is spelled identically on both gateways or is
24
+ OpenRouter-only. Default routing is direct-first and was never affected — no run selected a
25
+ wrong model.
26
+
27
+ ## [4.1.1] - 2026-07-21
28
+
29
+ ### Fixed
30
+
31
+ - **The integration tier is tested again, and now something actually watches it.** All 14
32
+ `tests/**/*.integration.test.js` files were unreachable from every gate — `jest.config.js` excludes
33
+ them from `npm test`, no workflow ran `npm run test:integration`, and the pre-push hook ran only the
34
+ unit suite. Six tests had been failing unnoticed behind a single dead model alias: three E2E suites
35
+ passed `--model gemini-flash`, which is not a live alias (`tryResolveModel('gemini-flash')` returns
36
+ `Unknown model alias`). They now pass `gemini`.
37
+ - **`--claude-review` reports no longer grow a blank `claude` judge column, or a false self-vote `*`.**
38
+ `report.js`'s `toModel()` reused `verdict.council` (the street-cred universe, which legitimately
39
+ includes `claude` on a `--claude-review` run) as the adjudication-matrix judge roster too. Claude is
40
+ judged but never judges, so the matrix grew an extra column that always rendered blank, or a bare
41
+ `*` on a row Claude itself raised — asserting Claude voted for its own finding, the opposite of the
42
+ documented guarantee. `judges` is now filtered out of `council` independently (gated on the
43
+ `claudeInCouncil` flag plus the `claude` name, never on whether a model cast any adjudications, so a
44
+ genuinely dead/unstructured bench judge with zero votes still keeps its blank column).
45
+ - **`debate-revote` now checkpoints `skipped`, not a false `complete`, when the re-vote wave never
46
+ launches** (nothing was defended/amended, or the cost ceiling skipped it). **This is a
47
+ consumer-visible `run.json` value change**: anything parsing `stages[].status` for this stage — the
48
+ Council Review Action's stage-ladder footer, `amicus status`, or a user script — now sees `skipped`
49
+ instead of `complete` on a run that hits this path. Neither the Action's footer nor `amicus
50
+ status`'s human/JSON renderers special-case `complete`, so both already print whatever value is
51
+ there correctly; a script that hard-coded an expectation of `complete` for this stage should treat
52
+ `skipped` as the equivalent no-op, the same way `run-chair.js`'s existing chair-skipped-over-budget
53
+ convention already works.
54
+ - **`amicus council verdict --render` now writes `report.html` at `0o600`**, matching every sibling
55
+ writer (`run-assemble.js`, `mcp-server.js`'s `amicus_verdict` `render:true` path, `run-launch.js`).
56
+ It previously wrote with no explicit mode, which falls back to the process umask (typically `0o644`
57
+ on POSIX — group/world-readable) whenever the target directory had no pre-existing `report.html`,
58
+ i.e. exactly the fresh-`--out-dir` case, for a file that holds model output.
59
+ - **Dropped the false `amicus wait` CLI claim.** `docs/usage.md` and `docs/council.md` both documented
60
+ `amicus status|wait|abort <councilRunId>` as working CLI commands. There is no CLI `wait` (verified
61
+ against `bin/amicus.js`'s command dispatch) — only `status` and `abort` genuinely resolve council
62
+ runs via the sessions-dir pointer file. Both docs now point readers at the MCP `amicus_wait` tool
63
+ instead.
64
+
65
+ ### Added
66
+
67
+ - **Keyless integration job in CI.** `.github/workflows/ci.yml` gained an `integration` job that runs
68
+ `npm run test:integration` with no secrets on every push and PR, so the tier is permanently watched
69
+ for free (~51 assertions, ~10s). `npm run test:integration` now goes through
70
+ `scripts/run-integration-keyless.js`, which strips every provider credential and sandboxes
71
+ `HOME`/`USERPROFILE` before spawning jest — the money-spending suites self-skip and the script cannot
72
+ bill even on a machine with keys on disk. The scrub derives its key names from the engine's own
73
+ `PROVIDER_ENV_MAP`, so it covers providers and paid suites added later without maintenance.
74
+ - **`npm run test:integration:live`** — the paid rail, split out so the CI job cannot silently start
75
+ billing if secrets are ever added to it. Run by the new `.github/workflows/integration-live.yml`
76
+ (`workflow_dispatch` only, carries `secrets.OPENROUTER_API_KEY`) and by the release checklist in
77
+ `docs/publishing.md`.
78
+
79
+ ### Fixed (docs)
80
+
81
+ - `docs/testing.md` and `CLAUDE.md` both claimed a pre-push integration gate that has never existed;
82
+ the pre-push hook runs the unit suite only, deliberately, so a local push never spends money. Both
83
+ now describe the real rails, and `.husky/pre-push`'s stale "until the 'Fix integration tests' task
84
+ lands" comment is replaced with the reason the hook stays unit-only.
85
+ - **`amicus doctor` no longer warns about Amicus's own shipped defaults — on a fresh install, and in
86
+ the keyless `model-drift.yml` CI check.** Both previously reported `⚠ Model aliases: 2 stale: opus,
87
+ haiku` (and the scheduled Model Drift Check ran red) with no user config involved.
88
+ `toDefaultAliases()` built each alias's pinned id by string-stripping the `openrouter/` prefix
89
+ instead of routing through the module's own `directFormFor()`, so it emitted OpenRouter's dot ids
90
+ for Anthropic — `anthropic/claude-opus-4.8`, `anthropic/claude-haiku-4.5` — which the direct API
91
+ rejects, and invented a bare `anthropic/claude-fable-5` for a model OpenRouter serves exclusively.
92
+ Defaults now come from `toGatewayRoutes()`, so an alias resolves to its authored direct form when
93
+ one exists (`anthropic/claude-opus-4-8`, `anthropic/claude-haiku-4-5-20251001`) and to its
94
+ OpenRouter route when none does (`openrouter/anthropic/claude-fable-5`). `fable`'s *recorded* form
95
+ changes, but this is **not a routing change** — OpenRouter was already the only gateway that serves
96
+ it, so a `fable` run resolves and routes identically before and after; only the id `doctor`/`amicus
97
+ models` display and compare against is different. Council artifacts are unaffected either way — they
98
+ record alias strings, not resolved ids.
99
+ - **This fix does not reach everyone who already ran `amicus setup`.** `createDefaultConfig()`
100
+ (`src/sidecar/setup.js`) persists the *entire* default alias map into `config.json` at setup time,
101
+ and `getEffectiveAliases()` lets that persisted user config win over the shipped defaults — so
102
+ anyone who ran `amicus setup` on Amicus ≤4.1.0 has the old, broken ids frozen on disk
103
+ (`source: user-config`), which `findStaleAliases()` never suppresses. Measured: such a user sees
104
+ **4 stale** aliases after upgrading to 4.1.1 — `opus`, `haiku`, `fable` (the pre-fix ids) plus
105
+ `gemini` (this release's own pin move, see Changed below) — not zero, and `amicus doctor` /
106
+ the Model Drift Check will keep warning for them. **There is no code fix for this in 4.1.1**; a
107
+ self-healing config migration is a 4.2 candidate. If `amicus doctor` still warns after upgrading,
108
+ re-run `amicus setup`, or fix individual aliases by hand, e.g.
109
+ `amicus setup --add-alias opus=anthropic/claude-opus-4-8`.
110
+ - The offline Anthropic model floor now also lists `anthropic/claude-haiku-4-5-20251001`, the dated id
111
+ Anthropic's own listing returns. Without it, keyless and OpenRouter-only users saw the shipped
112
+ `haiku` default reported stale against the shipped floor.
113
+
114
+ ### Changed
115
+
116
+ - **Curated `gemini` pin moves from Gemini 3.5 Flash to Gemini 3.6 Flash on both gateways.** This is a
117
+ **silent model change, not merely a drift-notice cleanup**: a user with no `gemini` alias override of
118
+ their own starts talking to a different underlying model the next time they use the `gemini` alias
119
+ after upgrading. It also clears the pinned-fallback drift notice from `amicus models --check`.
120
+
8
121
  ## [4.1.0] - 2026-07-21
9
122
 
10
123
  The `second-opinion` skill stops hand-driving councils. Stages 1–3 and the Stage-5 artifacts
package/README.md CHANGED
@@ -329,7 +329,7 @@ $ amicus status demo123 --json
329
329
  "taskId": "demo123",
330
330
  "status": "complete",
331
331
  "elapsed": "5m 0s",
332
- "version": "4.1.0",
332
+ "version": "4.1.2",
333
333
  "model": "google/gemini-2.5-flash",
334
334
  "phase": "terminal"
335
335
  }
@@ -352,7 +352,7 @@ amicus models --search gemini # filter by substring
352
352
 
353
353
  ### Routing
354
354
 
355
- - **Bare `provider/model`** (e.g. `openai/gpt-5.5`, `anthropic/claude-opus-4.8`, `google/gemini-3.5-flash`) is the canonical, policy-routed form — Amicus routes it **direct-first**: your direct provider key when one is configured, falling back to OpenRouter automatically when only an OpenRouter key exists.
355
+ - **Bare `provider/model`** (e.g. `openai/gpt-5.5`, `anthropic/claude-opus-4-8`, `google/gemini-3.6-flash`) is the canonical, policy-routed form — Amicus routes it **direct-first**: your direct provider key when one is configured, falling back to OpenRouter automatically when only an OpenRouter key exists.
356
356
  - **`openrouter/provider/model`** is an explicit override that always forces OpenRouter, even when a direct key is present — reach for it deliberately, or for gateway-only vendors with no direct integration (Qwen, Grok, Mistral, GLM, …).
357
357
  - **`--gateway auto|direct|openrouter`** (CLI, also on the MCP tools) overrides routing for one call; `auto` is the direct-first default. `routing.prefer` in `config.json` (`"direct"` by default, or `"openrouter"`) sets the global default.
358
358
  - **One-time migration notice:** if you hold both an OpenRouter key and a direct key for a vendor, the first launch that resolves to that vendor under **`auto`** routing (the default) prints a one-time notice that routing moved to direct API; set `routing.prefer: "openrouter"` to restore the old all-OpenRouter behavior. The notice never fires when you explicitly pass `--gateway direct` — that's your own choice, not a migration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
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": [
@@ -49,7 +49,8 @@
49
49
  "scripts": {
50
50
  "start": "node --experimental-top-level-await --experimental-vm-modules bin/amicus.js",
51
51
  "test": "jest",
52
- "test:integration": "jest --testPathIgnorePatterns='worktrees' --testMatch='**/tests/**/*.integration.test.js'",
52
+ "test:integration": "node scripts/run-integration-keyless.js",
53
+ "test:integration:live": "jest --testPathIgnorePatterns='worktrees' --testMatch='**/tests/**/*.integration.test.js'",
53
54
  "test:all": "jest --testPathIgnorePatterns='/node_modules/' --testPathIgnorePatterns='worktrees' && node scripts/mark-test-passed.js",
54
55
  "test:e2e:mcp": "jest tests/mcp-repomix-e2e.integration.test.js --testTimeout=180000 --forceExit",
55
56
  "posttest": "node scripts/mark-test-passed.js",
@@ -513,7 +513,7 @@ Use these together with `amicus council stats` (the ledger — authoritative qua
513
513
 
514
514
  Always **rank recommendations by fit**, state the trade-off for each option, and surface the estimated cost (an estimate, not a guarantee; unpriced legs disclosed as "cost unknown"). Never present a single option without explanation.
515
515
 
516
- **Model naming for council members.** Name bench members by alias (`gemini`, `gpt`, `deepseek`, `opus`, …) or by full `provider/model` id — both work with `--models`. A bare canonical id (e.g. `anthropic/claude-opus-4.8`) is policy-routed **direct-first**: Amicus uses the user's direct provider key when one is configured, falling back to OpenRouter automatically. `openrouter/provider/model` is an explicit force-OpenRouter override — reach for it only when the user deliberately wants a specific member to run through OpenRouter (e.g. to use a free-tier variant), or for gateway-only vendors with no direct integration. A per-run `--gateway auto|direct|openrouter` overrides routing for the whole run if the user asks for it; leave it unset (`auto`) by default.
516
+ **Model naming for council members.** Name bench members by alias (`gemini`, `gpt`, `deepseek`, `opus`, …) or by full `provider/model` id — both work with `--models`. A bare canonical id (e.g. `anthropic/claude-opus-4-8`) is policy-routed **direct-first**: Amicus uses the user's direct provider key when one is configured, falling back to OpenRouter automatically. `openrouter/provider/model` is an explicit force-OpenRouter override — reach for it only when the user deliberately wants a specific member to run through OpenRouter (e.g. to use a free-tier variant), or for gateway-only vendors with no direct integration. A per-run `--gateway auto|direct|openrouter` overrides routing for the whole run if the user asks for it; leave it unset (`auto`) by default.
517
517
 
518
518
  ---
519
519
 
@@ -79,8 +79,8 @@ Amicus uses the OpenCode SDK to communicate with LLM providers. You need to conf
79
79
 
80
80
  Read this before picking a model string — it decides both routing and which credentials get used:
81
81
 
82
- - **Bare `provider/model`** (e.g. `openai/gpt-5.5`, `anthropic/claude-opus-4.8`, `google/gemini-3.5-flash`) is the **canonical, policy-routed** form — use it by default. Amicus routes it **direct-first**: your direct provider key when one is configured, falling back to OpenRouter automatically when only `OPENROUTER_API_KEY` exists. You never pick the gateway by hand.
83
- - **`openrouter/provider/model`** (e.g. `openrouter/google/gemini-3.5-flash`) is an **explicit force-OpenRouter override** — it always routes through OpenRouter even when a direct key is present. Reach for it deliberately, or for gateway-only vendors with no direct integration at all (Qwen, Grok, Mistral, GLM, MiniMax, Kimi, Seed, …) — those *require* the `openrouter/` prefix.
82
+ - **Bare `provider/model`** (e.g. `openai/gpt-5.5`, `anthropic/claude-opus-4-8`, `google/gemini-3.6-flash`) is the **canonical, policy-routed** form — use it by default. Amicus routes it **direct-first**: your direct provider key when one is configured, falling back to OpenRouter automatically when only `OPENROUTER_API_KEY` exists. You never pick the gateway by hand.
83
+ - **`openrouter/provider/model`** (e.g. `openrouter/google/gemini-3.6-flash`) is an **explicit force-OpenRouter override** — it always routes through OpenRouter even when a direct key is present. Reach for it deliberately, or for gateway-only vendors with no direct integration at all (Qwen, Grok, Mistral, GLM, MiniMax, Kimi, Seed, …) — those *require* the `openrouter/` prefix.
84
84
  - Short aliases (`gemini`, `gpt`, `opus`, `claude`, `deepseek`, …) resolve to one of the two forms above via your config; `amicus setup` seeds direct-capable vendors to the bare canonical form automatically.
85
85
  - **Routing controls:** `routing.prefer` in `~/.config/amicus/config.json` sets the global default — `"direct"` (the default) or `"openrouter"` to always prefer OpenRouter. Per call, `--gateway auto|direct|openrouter` overrides it (`auto` = direct-first, same as the default).
86
86
  - **One-time migration notice:** if you hold both an OpenRouter key and a direct key for a vendor, the first launch that resolves to that vendor prints a one-time notice that routing moved to direct API (previously OpenRouter); set `routing.prefer: "openrouter"` (or pass `--gateway openrouter`) to restore the old all-OpenRouter behavior.
@@ -116,8 +116,8 @@ amicus start --model gemini --prompt "Say hello" --no-ui
116
116
  **With OpenRouter as your only key**, aliases and bare canonical ids both work — the router falls back to OpenRouter automatically since no direct key exists for the vendor:
117
117
  ```bash
118
118
  amicus start --model gemini --prompt "..." # alias
119
- amicus start --model google/gemini-3.5-flash --prompt "..." # bare canonical, falls back to OpenRouter
120
- amicus start --model openrouter/google/gemini-3.5-flash --prompt "..." # explicit override — same result here
119
+ amicus start --model google/gemini-3.6-flash --prompt "..." # bare canonical, falls back to OpenRouter
120
+ amicus start --model openrouter/google/gemini-3.6-flash --prompt "..." # explicit override — same result here
121
121
  ```
122
122
 
123
123
  ### Option B: Direct API Keys (Provider-Specific)
@@ -451,7 +451,7 @@ Use short aliases (run `amicus models` to see the live catalog, and `amicus mode
451
451
  - Omit `--model` entirely to use your configured default
452
452
 
453
453
  Full model strings also work: `--model provider/model-id` (bare canonical, direct-first — e.g.
454
- `--model anthropic/claude-opus-4.8`) or `--model openrouter/provider/model-id` (explicit
454
+ `--model anthropic/claude-opus-4-8`) or `--model openrouter/provider/model-id` (explicit
455
455
  force-OpenRouter override).
456
456
 
457
457
  ### Verifying Model Names
@@ -181,7 +181,7 @@ function runVerdict(args, useJson) {
181
181
  // v4.1 §4.5c: refresh report.html next to the decided verdict.
182
182
  try {
183
183
  const html = buildReport({ verdict }, { format: 'html' });
184
- fs.writeFileSync(path.join(path.dirname(outPath), 'report.html'), html);
184
+ fs.writeFileSync(path.join(path.dirname(outPath), 'report.html'), html, { mode: 0o600 });
185
185
  } catch (e) {
186
186
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `verdict written but render failed: ${e.message}`,
187
187
  hint: 'the verdict.json is valid; re-run `amicus council report <verdict.json> --html` manually' });
@@ -20,7 +20,19 @@ function toModel(verdict, wave) {
20
20
  if (!verdict || !Array.isArray(verdict.findings)) {
21
21
  throw new Error('verdict.json must have a findings[] array');
22
22
  }
23
- const judges = verdict.council || [];
23
+ const council = verdict.council || [];
24
+ // 'council' (verdict.council / meta.models) is the street-cred universe and
25
+ // legitimately includes 'claude' on a --claude-review run (run-assemble.js:
26
+ // 123-125, docs/council.md:326). 'judges' is the adjudication-matrix column
27
+ // set: SKILL.md:482 / run-stages.js:162-163 guarantee Claude is judged but
28
+ // never judges, so its reserved seat must never grow a matrix column — filter
29
+ // it out ONLY when claudeInCouncil is true. This is name+flag gated, not
30
+ // vote-derived: a bench judge that cast zero adjudications (dead/unstructured
31
+ // leg, run-stages.js:204-207) is still in council/judges and must still get
32
+ // its (blank) column — deriving the roster from "who actually voted" would
33
+ // silently delete that column too and break the byte-unchanged-artifact
34
+ // contract for degraded v4.0.1-shaped runs.
35
+ const judges = verdict.claudeInCouncil === true ? council.filter(j => j !== 'claude') : council;
24
36
  const findings = verdict.findings.map((f) => {
25
37
  const byJudge = {};
26
38
  for (const j of judges) { byJudge[j] = null; }
@@ -61,7 +73,7 @@ function toModel(verdict, wave) {
61
73
  return {
62
74
  header: {
63
75
  runType: verdict.runType || 'review', runId: verdict.runId, date: verdict.date,
64
- chair: verdict.chair, council: judges, claudeInCouncil: verdict.claudeInCouncil === true,
76
+ chair: verdict.chair, council, claudeInCouncil: verdict.claudeInCouncil === true,
65
77
  },
66
78
  tierCounts: verdict.tierCounts || { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 },
67
79
  judges, findings, debate,
@@ -223,7 +223,11 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
223
223
  // degradation branch (spec §5.7); skipping because there is simply nothing to re-vote is NOT.
224
224
  const wouldRevote = defendedOrAmended.length > 0 && judges.length > 0;
225
225
  const costCeiling = ctx.overBudget() && wouldRevote;
226
- if (wouldRevote && !costCeiling) {
226
+ // run.js needs to know whether the wave actually launched so it can
227
+ // checkpoint debate-revote 'skipped' (not a false 'complete') when nothing
228
+ // was defended/amended, or the cost ceiling skipped it (spec §5.7).
229
+ const revoteLaunched = wouldRevote && !costCeiling;
230
+ if (revoteLaunched) {
227
231
  const rv = await runRevoteWave(ctx, judges, defendedOrAmended);
228
232
  if (rv.aborted) { return { aborted: rv.aborted, contested, disputed }; }
229
233
  revoteByJudge = rv.byJudge;
@@ -283,7 +287,7 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
283
287
 
284
288
  return { debatedInput, debateFindings, debateSummary, addendumOutcomes,
285
289
  defenseLegs: defenseResults.map(d => d.leg), revoteLegs, verdictChanges,
286
- degraded, aborted: null };
290
+ degraded, aborted: null, revoteLaunched };
287
291
  }
288
292
 
289
293
  module.exports = { runDebate, nothingToDebate, disputingJudges, debateTargets };
@@ -198,7 +198,11 @@ async function runCouncil(options, deps = {}) {
198
198
  // run-debate owns debate-revote's running/waveId/waveIds checkpoint — only it
199
199
  // knows whether the wave launched. Never advertise a `-rv` id here: a skipped
200
200
  // re-vote would leave the abort cascade chasing the v4.0 lens `-s1` phantom.
201
- runState.updateStage(o.runDir, 'debate-revote', { status: 'complete', completedAt: now() });
201
+ // Mirror run-chair.js's 'skipped' convention (no startedAt) when nothing was
202
+ // defended/amended or the cost ceiling skipped it — 'complete' would report
203
+ // work that never happened.
204
+ runState.updateStage(o.runDir, 'debate-revote', dbg.revoteLaunched
205
+ ? { status: 'complete', completedAt: now() } : { status: 'skipped', completedAt: now() });
202
206
  ({ debatedInput, debateFindings, debateSummary } = dbg);
203
207
  debatedRecord = tally(debatedInput);
204
208
  // Defensive truthiness guard: `[]` is truthy in JS, so an empty outcomes
package/src/mcp-tools.js CHANGED
@@ -57,7 +57,7 @@ function getTools() {
57
57
  inputSchema: {
58
58
  model: safeModel.optional().describe(
59
59
  `Short alias (${aliasNames}) or full model ID ` +
60
- '(bare provider/model is canonical and routes direct-first, e.g. anthropic/claude-opus-4.8; ' +
60
+ '(bare provider/model is canonical and routes direct-first, e.g. anthropic/claude-opus-4-8; ' +
61
61
  'openrouter/provider/model forces OpenRouter). ' +
62
62
  'If omitted, uses the configured default. Call amicus_guide to see all aliases.'
63
63
  ),
@@ -581,7 +581,7 @@ Include: Objective, Background, Files of interest, Success criteria, Constraints
581
581
  |-------|-------|
582
582
  ${aliasRows}
583
583
 
584
- Or use a full model ID. Bare \`provider/model\` (e.g. anthropic/claude-opus-4.8) is the canonical,
584
+ Or use a full model ID. Bare \`provider/model\` (e.g. anthropic/claude-opus-4-8) is the canonical,
585
585
  policy-routed form — it routes direct-first (your direct provider key if configured, else
586
586
  OpenRouter). \`openrouter/provider/model\` is an explicit override that forces OpenRouter. The
587
587
  \`gateway\` param (or \`routing.prefer\` in config.json) controls this per call or globally.
@@ -353,8 +353,7 @@ async function runReadlineSetup() {
353
353
  return;
354
354
  }
355
355
 
356
- const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
357
- const { toCanonicalDefault } = require('../utils/curated-models');
356
+ const { resolveQuickPicks, toLiveSeedAliases, toStorableRoute } = require('../utils/quick-picks');
358
357
  const picks = resolveQuickPicks(catalog);
359
358
 
360
359
  console.log('Choose your default model:');
@@ -386,7 +385,7 @@ async function runReadlineSetup() {
386
385
  // choice), but the alias's VALUE must stay the vendor phase's tier choice --
387
386
  // skip the curated-flagship upgrade so it isn't discarded.
388
387
  if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
389
- cfg.aliases[chosen.alias] = toCanonicalDefault(pick.routes.openrouter || Object.values(pick.routes)[0]);
388
+ cfg.aliases[chosen.alias] = toStorableRoute(pick);
390
389
  } else if (cfg.aliases[chosen.alias] === undefined) {
391
390
  const fallback = getDefaultAliases()[chosen.alias];
392
391
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
@@ -12,8 +12,9 @@ const { autoRepairAlias } = require('./alias-resolver');
12
12
  const { isDirectProvider } = require('./provider-registry');
13
13
 
14
14
  /** Default model alias map — derived from the curated-models single source (F5) */
15
- const { toDefaultAliases } = require('./curated-models');
15
+ const { toDefaultAliases, toGatewayRoutes } = require('./curated-models');
16
16
  const DEFAULT_ALIASES = toDefaultAliases();
17
+ const CURATED_ROUTES = toGatewayRoutes();
17
18
 
18
19
  /** Built-in council benches (B23) — consulted only when a name is absent from user config. */
19
20
  const { resolveBuiltinCouncil } = require('./council-presets');
@@ -261,10 +262,19 @@ function tryResolveModel(modelArg) {
261
262
  * threading only that first session's resolved id is insufficient. So for
262
263
  * every alias that resolves to a BARE direct-capable-vendor id (post-#61
263
264
  * default aliases are bare, e.g. `openai/gpt-5.5`), this ALSO registers the
264
- * `openrouter/<vendor>/<model>` form — broadening the catalog to cover BOTH
265
- * routes the router might pick, regardless of which session created the
266
- * server. This does NOT change what the alias itself resolves to (still
267
- * bare, still direct-first) — it only widens what's pre-registered.
265
+ * alias's OpenRouter form — broadening the catalog to cover BOTH routes the
266
+ * router might pick, regardless of which session created the server. This
267
+ * does NOT change what the alias itself resolves to (still bare, still
268
+ * direct-first) — it only widens what's pre-registered.
269
+ *
270
+ * The mirror is read from the alias's authored gateway route, NOT built by
271
+ * prepending `openrouter/`. For DIVERGENT_VENDORS the two ids are different
272
+ * strings, not merely differently prefixed — OpenRouter serves
273
+ * `anthropic/claude-opus-4.8` while the direct API serves
274
+ * `anthropic/claude-opus-4-8`. v4.1.1 made `toDefaultAliases()` emit the
275
+ * direct form, at which point prepending produced an id OpenRouter does not
276
+ * serve (fixed in v4.1.2). Aliases the user has overridden fall back to the
277
+ * prefix form, since no authored gateway route describes them.
268
278
  * @param {string[]} [resolvedRoutes] executable model id(s) actually launched
269
279
  * @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
270
280
  function buildProviderModels(resolvedRoutes = []) {
@@ -285,13 +295,23 @@ function buildProviderModels(resolvedRoutes = []) {
285
295
  providers[providerID].models[modelID] = {};
286
296
  };
287
297
 
288
- for (const fullModel of Object.values(aliases)) {
298
+ for (const [alias, fullModel] of Object.entries(aliases)) {
289
299
  addRoute(fullModel);
290
300
 
291
301
  // Broaden: a bare direct-capable-vendor route also gets an OpenRouter
292
- // mirror registered (see catalog-broadening note above). Gateway-only
293
- // aliases (already `openrouter/...`, e.g. grok/qwen/x-ai) are untouched
294
- // OpenRouter is their only possible route anyway, already covered above.
302
+ // mirror registered (see catalog-broadening note above). Prefer the
303
+ // alias's AUTHORED OpenRouter route for divergent vendors it is a
304
+ // different id, not a prefixed one. Guarded on the alias still holding its
305
+ // shipped direct value so a user override never inherits a curated route.
306
+ const curated = CURATED_ROUTES[alias];
307
+ if (curated && curated.openrouter && curated.direct === fullModel) {
308
+ addRoute(curated.openrouter);
309
+ continue;
310
+ }
311
+
312
+ // Fallback for user-defined aliases. Gateway-only aliases (already
313
+ // `openrouter/...`, e.g. grok/qwen/x-ai) are untouched — OpenRouter is
314
+ // their only possible route anyway, already covered above.
295
315
  if (typeof fullModel === 'string' && !fullModel.startsWith('openrouter/')) {
296
316
  const vendor = fullModel.split('/')[0];
297
317
  if (isDirectProvider(vendor)) {
@@ -30,8 +30,8 @@ const FAMILIES = [
30
30
  vendorPath: 'google',
31
31
  idPattern: /^gemini-[\d.]+-flash(-preview|-exp|-latest)?$/,
32
32
  directProviders: ['google'],
33
- fallback: { openrouter: 'openrouter/google/gemini-3.5-flash',
34
- google: 'google/gemini-3.5-flash' } },
33
+ fallback: { openrouter: 'openrouter/google/gemini-3.6-flash',
34
+ google: 'google/gemini-3.6-flash' } },
35
35
  { alias: 'gemini-pro', label: 'Gemini Pro-class', blurb: 'advanced reasoning',
36
36
  vendorPath: 'google',
37
37
  idPattern: /^gemini-[\d.]+-pro(-preview|-exp|-latest)?$/,
@@ -119,24 +119,6 @@ function toCanonicalDefault(route) {
119
119
  return route;
120
120
  }
121
121
 
122
- /**
123
- * @returns {Object<string,string>} alias → pinned route, direct-first for
124
- * direct-capable vendors (bare `vendor/model`), openrouter-prefixed for
125
- * gateway-only vendors. STATIC — runtime-safe.
126
- */
127
- function toDefaultAliases() {
128
- const out = {};
129
- for (const f of FAMILIES) {
130
- const route = f.fallback.openrouter || Object.values(f.fallback)[0];
131
- out[f.alias] = toCanonicalDefault(route);
132
- }
133
- for (const e of CARDLESS) {
134
- const route = e.routes.openrouter || Object.values(e.routes)[0];
135
- out[e.alias] = toCanonicalDefault(route);
136
- }
137
- return out;
138
- }
139
-
140
122
  /**
141
123
  * @returns {Array<{alias,provider,model}>} every pinned route, flattened (for the alias audit).
142
124
  */
@@ -213,6 +195,26 @@ function toGatewayRoutes() {
213
195
  return out;
214
196
  }
215
197
 
198
+ /**
199
+ * @returns {Object<string,string>} alias → the SINGLE pinned route used for
200
+ * display and `config.default`: the alias's authored direct form when one
201
+ * exists, else its OpenRouter route. STATIC — runtime-safe, never networks.
202
+ *
203
+ * Derived from `toGatewayRoutes()` on purpose, so the two builders can never
204
+ * disagree. It previously string-stripped `openrouter/` itself, which emitted
205
+ * OpenRouter's dot ids for divergent vendors (`anthropic/claude-opus-4.8` —
206
+ * the direct API only serves the dash form) and invented a bare direct id for
207
+ * OpenRouter-only models (`fable`). Both made `amicus doctor` and `amicus
208
+ * models --check` warn about the product's own shipped defaults.
209
+ */
210
+ function toDefaultAliases() {
211
+ const out = {};
212
+ for (const [alias, routes] of Object.entries(toGatewayRoutes())) {
213
+ out[alias] = routes.direct || routes.openrouter;
214
+ }
215
+ return out;
216
+ }
217
+
216
218
  module.exports = {
217
219
  getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes, DIVERGENT_VENDORS
218
220
  };
@@ -19,6 +19,12 @@ const ANTHROPIC_MODELS = [
19
19
  { id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
20
20
  { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
21
21
  { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
22
+ // Dated snapshot: the id Anthropic's /v1/models actually lists, and the
23
+ // `haiku` direct route curated-models.js authors. Without it the floor
24
+ // (the only anthropic/ rows a keyless or OpenRouter-only user ever has)
25
+ // reports the shipped `haiku` default as stale.
26
+ { id: 'anthropic/claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5 (2025-10-01)',
27
+ contextLength: null, pricing: null },
22
28
  { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null }
23
29
  ];
24
30
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { getFamilies, toDefaultAliases, toCanonicalDefault } = require('./curated-models');
13
+ const { getFamilies, toDefaultAliases, toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
14
14
 
15
15
  const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
16
16
 
@@ -45,7 +45,7 @@ function pickCurrent(catalog, nsPrefix, vendorPath, idPattern) {
45
45
 
46
46
  /**
47
47
  * @param {Array<{id:string}>} catalog
48
- * @returns {Array<{alias,label,blurb,source:'live'|'fallback',routes:Object<string,string>}>}
48
+ * @returns {Array<{alias,label,blurb,vendorPath,source:'live'|'fallback',routes:Object<string,string>}>}
49
49
  * `routes` may be empty if a family defines no fallback and the catalog has no match.
50
50
  */
51
51
  function resolveQuickPicks(catalog) {
@@ -60,30 +60,53 @@ function resolveQuickPicks(catalog) {
60
60
  if (direct) { routes[p] = direct; live = true; }
61
61
  else if (f.fallback[p]) { routes[p] = f.fallback[p]; }
62
62
  }
63
- return { alias: f.alias, label: f.label, blurb: f.blurb, routes,
63
+ return { alias: f.alias, label: f.label, blurb: f.blurb, vendorPath: f.vendorPath, routes,
64
64
  source: live ? 'live' : 'fallback' };
65
65
  });
66
66
  }
67
67
 
68
+ /**
69
+ * The single route value a wizard may STORE for a resolved quick pick.
70
+ *
71
+ * For a direct-capable vendor the OpenRouter pick is canonicalised to bare
72
+ * `vendor/model` so it stays direct-first via the gateway router — otherwise a
73
+ * fresh `amicus setup` with a live catalog would silently defeat the
74
+ * direct-first default `toDefaultAliases()` establishes.
75
+ *
76
+ * For a DIVERGENT vendor the direct id is never DERIVED from the OpenRouter
77
+ * one: they are different strings, not differently prefixed (OpenRouter serves
78
+ * `anthropic/claude-opus-4.8`, the direct API `anthropic/claude-opus-4-8`).
79
+ * Stripping the prefix there fabricates an id the direct API rejects, which
80
+ * `amicus doctor` then reports as a stale alias. The row's own direct route is
81
+ * used verbatim, falling back to the intact `openrouter/` form when the
82
+ * catalog offered no direct pick. Mirrors the guard already used at
83
+ * `provider-default-picker.js:82,143,220`.
84
+ * @param {{vendorPath?:string, routes?:Object<string,string>}} pick
85
+ * @returns {string|undefined}
86
+ */
87
+ function toStorableRoute(pick) {
88
+ const routes = (pick && pick.routes) || {};
89
+ if (pick && DIVERGENT_VENDORS.has(pick.vendorPath)) {
90
+ return routes[pick.vendorPath] || routes.openrouter;
91
+ }
92
+ return toCanonicalDefault(routes.openrouter || Object.values(routes)[0]);
93
+ }
94
+
68
95
  /**
69
96
  * Seed map for fresh configs: static defaults overlaid with live family
70
- * routes (cardless aliases stay pinned). The overlaid route is run through
71
- * `toCanonicalDefault` so a direct-capable vendor (e.g. google, openai)
72
- * lands as bare `vendor/model` (direct-first via the gateway router)
73
- * instead of the raw `openrouter/<vendor>/<rest>` pick — otherwise a fresh
74
- * `amicus setup` with a live catalog would silently defeat the direct-first
75
- * default `toDefaultAliases()` establishes. Gateway-only vendors are
76
- * returned unchanged by `toCanonicalDefault`.
97
+ * routes (cardless aliases stay pinned). See `toStorableRoute` for why the
98
+ * overlaid value is not a raw prefix strip.
77
99
  * @returns {Object<string,string>}
78
100
  */
79
101
  function toLiveSeedAliases(catalog) {
80
102
  const seeds = toDefaultAliases();
81
103
  for (const r of resolveQuickPicks(catalog || [])) {
82
104
  if (r.source === 'live' && r.routes.openrouter) {
83
- seeds[r.alias] = toCanonicalDefault(r.routes.openrouter);
105
+ const stored = toStorableRoute(r);
106
+ if (stored) { seeds[r.alias] = stored; }
84
107
  }
85
108
  }
86
109
  return seeds;
87
110
  }
88
111
 
89
- module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases };
112
+ module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };