amicus 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +66 -0
  3. package/README.md +13 -4
  4. package/electron/setup-ui-aliases.js +1 -1
  5. package/electron/setup-ui.js +27 -3
  6. package/package.json +2 -1
  7. package/skills/second-opinion/SKILL.md +2 -0
  8. package/skills/sidecar/SKILL.md +66 -38
  9. package/src/cli-handlers-resume-continue.js +31 -4
  10. package/src/cli-handlers-run.js +15 -4
  11. package/src/cli.js +17 -0
  12. package/src/mcp-server.js +99 -12
  13. package/src/mcp-tools.js +26 -4
  14. package/src/opencode-client.js +18 -2
  15. package/src/sidecar/continue.js +10 -3
  16. package/src/sidecar/fanout-leg.js +26 -1
  17. package/src/sidecar/fanout-output.js +5 -0
  18. package/src/sidecar/fanout-validate.js +81 -0
  19. package/src/sidecar/fanout.js +65 -77
  20. package/src/sidecar/models.js +39 -17
  21. package/src/sidecar/session-utils.js +6 -0
  22. package/src/sidecar/setup.js +2 -1
  23. package/src/utils/alias-resolver.js +6 -35
  24. package/src/utils/api-key-store.js +1 -9
  25. package/src/utils/auth-json.js +1 -1
  26. package/src/utils/config.js +98 -16
  27. package/src/utils/curated-models.js +99 -8
  28. package/src/utils/gateway-route-audit.js +103 -0
  29. package/src/utils/gateway-route-catalog.js +92 -0
  30. package/src/utils/gateway-router.js +131 -0
  31. package/src/utils/input-validators.js +12 -42
  32. package/src/utils/model-classification.js +65 -0
  33. package/src/utils/model-descriptor.js +72 -0
  34. package/src/utils/model-fetcher.js +46 -14
  35. package/src/utils/model-input-default.js +32 -0
  36. package/src/utils/model-validator.js +68 -84
  37. package/src/utils/provider-registry.js +57 -0
  38. package/src/utils/quick-picks.js +11 -3
  39. package/src/utils/result-schema-rebuild.js +98 -0
  40. package/src/utils/result-schema.js +15 -78
  41. package/src/utils/route-error.js +154 -0
  42. package/src/utils/route-launch.js +219 -0
  43. package/src/utils/start-helpers.js +96 -43
  44. package/src/utils/validators.js +1 -8
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
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,72 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.1.1] - 2026-07-16
9
+
10
+ ### Fixed
11
+
12
+ - **Anthropic model aliases now route correctly for direct-Anthropic-key users.** `--model opus` /
13
+ `haiku` / `claude` / `sonnet` previously resolved to OpenRouter's dot-form id (e.g.
14
+ `anthropic/claude-opus-4.8`), which the direct Anthropic API rejects with `model_not_found` (it uses
15
+ dashes/date suffixes: `claude-opus-4-8`, `claude-haiku-4-5-20251001`). Aliases now carry per-gateway
16
+ executable ids and the router emits the selected gateway's native id. OpenRouter-only users were
17
+ unaffected.
18
+
19
+ ### Changed
20
+
21
+ - `--model claude` / `--model sonnet` default target moves from Claude Sonnet 4.6 to **Claude Sonnet
22
+ 5**; the offline model floor was refreshed to the current Anthropic family.
23
+ - Availability-aware routing: a model not served on the selected gateway (e.g. Fable, which is
24
+ OpenRouter-only) routes to the gateway that has it, or errors clearly under an explicit `--gateway`.
25
+
26
+ ### Added
27
+
28
+ - `amicus models --check --strict` exits non-zero on curated default-alias drift; a scheduled
29
+ `model-drift` CI workflow audits the per-gateway ids against the live (keyless) OpenRouter catalog.
30
+
31
+ ## [3.1.0] - 2026-07-15
32
+
33
+ ### Added
34
+
35
+ - **Direct-first gateway routing** (#61): bare `provider/model` model IDs (e.g. `anthropic/claude-opus-4-5`)
36
+ now route to your **direct** provider key when one is configured, falling back to OpenRouter only when
37
+ it isn't. An explicit `openrouter/...`-prefixed model ID remains a force-OpenRouter override — that
38
+ literal form never changes behavior.
39
+ - New `--gateway auto|direct|openrouter` CLI flag on `start`, `fanout`, and `continue` (`auto` is the
40
+ direct-first default) and a matching `gateway` enum on the MCP `amicus_start` / `amicus_fanout` /
41
+ `amicus_continue` tools.
42
+ - New `routing.prefer` config key (`"direct"` default | `"openrouter"`) sets the global default; the
43
+ per-call `--gateway`/`gateway` param overrides it for that run.
44
+ - Non-interactive CLI (`--json`) and MCP now emit a structured `model_route_error` (`type`, `field`,
45
+ `requested`, `reason`) instead of an ad hoc message when a request can't be routed — identical shape
46
+ on both surfaces.
47
+ - Interactive runs get a picker with alternatives when a direct route misses (e.g. key missing or model
48
+ not on that vendor's live catalog), instead of failing outright.
49
+ - Live Anthropic model fetcher: the model catalog now queries Anthropic's API directly for the current
50
+ model list, the same live-fetch treatment OpenAI and Google already had.
51
+ - Session provenance (resume/continue) preserves the gateway a run originally resolved to, even if keys
52
+ or `routing.prefer` change in between.
53
+
54
+ ### Changed
55
+
56
+ - **Default aliases for direct-capable vendors** (`openai`, `google`, `anthropic`, `deepseek`) now resolve
57
+ to bare canonical model IDs instead of `openrouter/...`-prefixed ones, so they participate in
58
+ direct-first routing out of the box. Gateway-only vendors (`qwen`, `grok`, `glm`, and other
59
+ OpenRouter-exclusive families) are unchanged — they still resolve through OpenRouter, since there's no
60
+ direct key path for them.
61
+ - **Migration:** if you hold both an OpenRouter key and a direct key for one of the four vendors above,
62
+ the next run against that vendor moves you to the direct route and prints a one-time notice; it's
63
+ silent after that. Set `routing.prefer: "openrouter"` in config (or pass `--gateway openrouter` /
64
+ `gateway: "openrouter"` per call) to keep routing everything through OpenRouter as before. Aliases you
65
+ already overrode via `amicus setup --add-alias` are untouched.
66
+
67
+ ### Notes
68
+
69
+ - Builds on the #61 gateway-routing foundation (router core, resolution modes, key discovery) merged to
70
+ main ahead of this release; this release wires that router into the live launch path (CLI + MCP), adds
71
+ the control surface (`--gateway` / `gateway` / `routing.prefer`), and switches default guidance to the
72
+ direct-first form.
73
+
8
74
  ## [3.0.0] - 2026-07-15
9
75
 
10
76
  ### ⚠️ Breaking
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A multi-model LLM Council for Claude — with a parallel AI window underneath.**
6
6
 
7
- ![The Amicus council mid-ritual: five models — Gemini 3 Pro, Llama 4, Grok 4, Claude Opus — reading the same material independently, chaired by GPT-5](./docs/council.png)
7
+ ![The Amicus council mid-ritual: five models — Gemini, Llama, Grok, Claude Opus — reading the same material independently, chaired by GPT](./docs/council.png)
8
8
 
9
9
  Hand Claude a plan, a design, a diff, an architecture decision, a manuscript — anything — and say *council review this*: Amicus routes it through several models from different families, has them anonymously cross-review each other, and a non-Claude chair synthesizes a verdict you turn into accept/deny edits. Or skip the ceremony and **fork** a single conversation to Gemini, GPT, DeepSeek, or any other model — it works in parallel with full context, and you **fold** the result back when you're ready. Claude orchestrates throughout; you stay in your editor.
10
10
 
@@ -319,7 +319,7 @@ $ amicus status demo123 --json
319
319
  "taskId": "demo123",
320
320
  "status": "complete",
321
321
  "elapsed": "5m 0s",
322
- "version": "3.0.0",
322
+ "version": "3.1.1",
323
323
  "model": "google/gemini-2.5-flash",
324
324
  "phase": "terminal"
325
325
  }
@@ -338,7 +338,16 @@ amicus models # list the catalog
338
338
  amicus models --search gemini # filter by substring
339
339
  ```
340
340
 
341
- `start`/`fanout` validate your model against the catalog before launching (skip with `--no-validate-model`). You can also always bypass aliases and pass a full `provider/model` or `openrouter/provider/model` ID directly. Catalog internals, alias management, and the full-id passthrough table are in **[docs/usage.md § Models](./docs/usage.md#amicus-models--the-model-catalog)**.
341
+ `start`/`fanout` validate your model against the catalog before launching (skip with `--no-validate-model`). You can also always bypass aliases and pass a full model ID directly — bare `provider/model` (canonical) or `openrouter/provider/model` (explicit override); see Routing below. Catalog internals, alias management, and the full-id passthrough table are in **[docs/usage.md § Models](./docs/usage.md#amicus-models--the-model-catalog)**.
342
+
343
+ ### Routing
344
+
345
+ - **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.
346
+ - **`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, …).
347
+ - **`--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.
348
+ - **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.
349
+
350
+ Full details, the API-key/prefix table, and the migration notice are in **[docs/configuration.md](./docs/configuration.md)**.
342
351
 
343
352
  ---
344
353
 
@@ -417,7 +426,7 @@ Run `amicus doctor` first — it checks keys, catalog, OpenCode binary, Electron
417
426
  | "council review this" does nothing | The `second-opinion` skill isn't installed | Check `~/.claude/skills/second-opinion/SKILL.md` exists; re-run `npm install -g amicus` (postinstall installs both skills) |
418
427
  | `npm install -g amicus` fails with `EEXIST: … claude-sidecar` | The old upstream `claude-sidecar` package is still installed globally; npm won't overwrite another package's bin shims | `npm uninstall -g claude-sidecar`, then `npm install -g amicus`. Your keys and past sessions are not lost, but v2.0.0 no longer reads the old paths automatically — see [docs/SHIMS.md](./docs/SHIMS.md) for the one-time migration steps (rename `~/.config/sidecar/` and any `.claude/sidecar_sessions/` dirs). |
419
428
  | Install fails partway, or `amicus doctor` reports the OpenCode binary "not found" | A **transient** error during the OpenCode engine's own postinstall (a spawn `ENOENT`, or an antivirus file-lock while it lays down its 11 per-platform binaries) can roll back the whole atomic install — retrying usually succeeds | Just re-run `npm install -g amicus`. If it still fails, clear the cache first: `npm cache clean --force && npm install -g amicus`. |
420
- | `401` / auth error | API key missing, or the model prefix doesn't match the key you have | Run `amicus setup`; make sure the prefix (`openrouter/…` vs `google/…` vs `openai/…` vs `anthropic/…`) matches the credentials you configured. |
429
+ | `401` / auth error | No usable key for the model's vendor bare `provider/model` ids fall back to `OPENROUTER_API_KEY` automatically, so this means neither the direct key nor an OpenRouter key is configured (or `--gateway direct`/`openrouter` forced a gateway whose key is missing) | Run `amicus setup`, or `amicus key <provider> <key>` to add the missing key; see [Routing](#routing). |
421
430
  | `402` / "Payment Required" on first council review / `start` / `fanout` call | Your OpenRouter key is real but has no credit. Key save (`amicus key openrouter <key>` or the setup wizard's key step) only checks that the key **authenticates** — it doesn't check balance, so a zero-credit key saves cleanly and only fails later, on the first real model call. (The `amicus council` subcommand itself is deterministic math and never calls a model.) | Add credit at [openrouter.ai/credits](https://openrouter.ai/credits), **or** switch to a zero-cost council: `amicus setup` → option 2 (Free OpenRouter council) builds one from live `:free`-suffixed models and saves it as `councils.free` — then run `amicus fanout --council free …`. See "Free council (zero-cost)" under [The Council](#the-council) above. |
422
431
  | Session not found | No session matches the given ID | Run `amicus list`, or omit `--session-id` to use the most recent. |
423
432
  | No conversation history found | Project-path encoding | Check `~/.claude/projects/`; `/` and `_` in the project path are encoded as `-` in the directory name. |
@@ -9,7 +9,7 @@
9
9
  const ALIAS_GROUPS = [
10
10
  { name: 'Gemini', keys: ['gemini', 'gemini-pro'] },
11
11
  { name: 'GPT', keys: ['gpt', 'gpt-pro', 'codex'] },
12
- { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku'] },
12
+ { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku', 'fable'] },
13
13
  { name: 'DeepSeek', keys: ['deepseek'] },
14
14
  { name: 'Qwen', keys: ['qwen', 'qwen-coder', 'qwen-flash'] },
15
15
  { name: 'Mistral', keys: ['mistral', 'devstral'] },
@@ -10,6 +10,7 @@ const { getDefaultAliases } = require('../src/utils/config');
10
10
  const { getBrandName } = require('./toolbar');
11
11
  const { resolveQuickPicks } = require('../src/utils/quick-picks');
12
12
  const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
13
+ const { listDirectProviders } = require('../src/utils/provider-registry');
13
14
 
14
15
  /**
15
16
  * @param {object} [options={}]
@@ -32,6 +33,7 @@ function buildSetupHTML(options = {}) {
32
33
  const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
33
34
  const defaultAliasesJson = JSON.stringify(getDefaultAliases());
34
35
  const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
36
+ const directProvidersJson = JSON.stringify(listDirectProviders());
35
37
  return `<!DOCTYPE html>
36
38
  <html><head><meta charset="utf-8"><title>Amicus Setup</title>
37
39
  <style>${css}</style></head><body>
@@ -53,11 +55,11 @@ function buildSetupHTML(options = {}) {
53
55
  </div>
54
56
  </div>
55
57
  <div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
56
- ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
58
+ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson)}
57
59
  </body></html>`;
58
60
  }
59
61
 
60
- function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
62
+ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson) {
61
63
  const keysJs = buildKeysScript();
62
64
  const aliasJs = buildAliasScript();
63
65
  const councilJs = buildCouncilScript();
@@ -72,7 +74,9 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
72
74
  var providerNamesData = ${providerNamesJson};
73
75
  var defaultAliases = ${defaultAliasesJson};
74
76
  var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
77
+ var directProviders = ${directProvidersJson};
75
78
  var routingChoices = {};
79
+ var explicitRouteChoices = {};
76
80
  var aliasEdits = {};
77
81
  var aliasDisplay = {};
78
82
  window.availableModels = null;
@@ -195,6 +199,21 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
195
199
  } else { nextBtn.disabled = false; }
196
200
  }
197
201
 
202
+ // #61: an auto-selected (non-explicit) openrouter/<vendor>/<model> route
203
+ // whose vendor also has a direct integration must be stored bare
204
+ // (<vendor>/<model>) so the gateway router can policy-route it direct-first;
205
+ // a stored openrouter/... string is treated as an explicit force-OpenRouter
206
+ // literal and never reconsiders direct-first. Mirrors
207
+ // src/utils/curated-models.js's toCanonicalDefault exactly. Gateway-only
208
+ // vendors (not in directProviders, e.g. qwen/grok/glm/...) pass through
209
+ // unchanged since OpenRouter is their only route anyway.
210
+ function toBareIfDirect(route) {
211
+ if (typeof route !== 'string' || route.indexOf('openrouter/') !== 0) { return route; }
212
+ var rest = route.slice('openrouter/'.length);
213
+ var vendor = rest.split('/')[0];
214
+ return directProviders.indexOf(vendor) !== -1 ? rest : route;
215
+ }
216
+
198
217
  // Single source of the route choice for a quick-pick row: explicit pill
199
218
  // choice if its key still exists, else first provider with a key, else
200
219
  // the row's first route. Returns the full model id or null.
@@ -209,7 +228,11 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
209
228
  }
210
229
  if (!prov) { prov = provs[0]; }
211
230
  }
212
- return mc.routes[prov] || null;
231
+ var route = mc.routes[prov] || null;
232
+ // Only canonicalize auto-picks; an explicit "via OpenRouter" pill click
233
+ // is a deliberate choice and is returned unchanged.
234
+ if (route && !explicitRouteChoices[mc.alias]) { route = toBareIfDirect(route); }
235
+ return route;
213
236
  }
214
237
 
215
238
  function updateRoutingPills() {
@@ -367,6 +390,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
367
390
  var provider = pill.getAttribute('data-provider');
368
391
  if (!alias || !provider) { return; }
369
392
  routingChoices[alias] = provider;
393
+ explicitRouteChoices[alias] = true;
370
394
  var toggle = pill.parentElement;
371
395
  toggle.querySelectorAll('.route-pill').forEach(function(p) { p.classList.toggle('active', p === pill); });
372
396
  updateWritePreviews();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
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": [
@@ -59,6 +59,7 @@
59
59
  "refresh-models": "node bin/amicus.js models --refresh",
60
60
  "models:info": "node bin/amicus.js models",
61
61
  "models:check": "node bin/amicus.js models --check",
62
+ "models:check:strict": "node bin/amicus.js models --check --strict",
62
63
  "generate-icon": "node scripts/generate-icon.js",
63
64
  "generate-docs": "node scripts/generate-docs.js",
64
65
  "generate-docs:check": "node scripts/generate-docs.js --check",
@@ -523,6 +523,8 @@ Use these together with `amicus council stats` (the ledger — authoritative qua
523
523
 
524
524
  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.
525
525
 
526
+ **Model naming for council members.** Name bench members by alias (`gemini`, `gpt`, `deepseek`, `opus`, …) or by full `provider/model` id — both work with `--models`/`amicus_fanout`. 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` (also on `amicus_fanout`/`amicus_start`) overrides routing for the whole wave if the user asks for it; leave it unset (`auto`) by default.
527
+
526
528
  ---
527
529
 
528
530
  ## Output & naming
@@ -75,9 +75,19 @@ Amicus uses the OpenCode SDK to communicate with LLM providers. You need to conf
75
75
 
76
76
  **Recommended:** run `amicus setup` — a guided wizard that stores keys in `~/.config/amicus/.env`, picks your default model from the live catalog, and seeds aliases. The options below are manual alternatives.
77
77
 
78
+ ### Model Naming: Bare Canonical Is the Normal Form
79
+
80
+ Read this before picking a model string — it decides both routing and which credentials get used:
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.
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
+ - **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
+ - **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.
87
+
78
88
  ### Option A: OpenRouter (Recommended for Multi-Model Access)
79
89
 
80
- OpenRouter provides unified access to many models (Gemini, GPT-4, Claude, o3, etc.) with a single API key. If you'd rather not hand-edit JSON, run `amicus setup` instead — it stores the key for you.
90
+ OpenRouter provides unified access to many models (Gemini, GPT, Claude, o3, etc.) with a single API key. If you'd rather not hand-edit JSON, run `amicus setup` instead — it stores the key for you.
81
91
 
82
92
  **Step 1: Get an OpenRouter API key**
83
93
  - Sign up at https://openrouter.ai
@@ -103,12 +113,11 @@ EOF
103
113
  amicus start --model gemini --prompt "Say hello" --no-ui
104
114
  ```
105
115
 
106
- **Model names with OpenRouter:**
107
- When using OpenRouter, prefix the model with `openrouter/`:
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:
108
117
  ```bash
109
- amicus start --model gemini --prompt "..."
110
- amicus start --model gpt --prompt "..."
111
- amicus start --model claude --prompt "..."
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
112
121
  ```
113
122
 
114
123
  ### Option B: Direct API Keys (Provider-Specific)
@@ -136,8 +145,8 @@ Add these to your shell profile (`~/.bashrc`, `~/.zshrc`) for persistence.
136
145
  - Run `amicus setup` to store keys in amicus's config (recommended)
137
146
  - Move your exports to `~/.zshenv` (sourced by all zsh shell types)
138
147
 
139
- **Model names with direct API keys:**
140
- When using direct API keys, use the provider/model format WITHOUT the `openrouter/` prefix:
148
+ **Model names with a direct key configured:**
149
+ Use the bare canonical `provider/model` form — direct-first routing picks up these keys automatically, no `openrouter/` prefix needed:
141
150
  ```bash
142
151
  amicus start --model google/<model-name> --prompt "..."
143
152
  amicus start --model openai/<model-name> --prompt "..."
@@ -146,18 +155,15 @@ amicus start --model anthropic/<model-name> --prompt "..."
146
155
 
147
156
  ### Model Naming Summary
148
157
 
149
- | Provider Access | Model Name Format | Example |
150
- |-----------------|-------------------|---------|
151
- | OpenRouter | `openrouter/provider/model` | `openrouter/google/<model-name>` |
152
- | Direct Google API | `google/model` | `google/<model-name>` |
153
- | Direct OpenAI API | `openai/model` | `openai/<model-name>` |
154
- | Direct Anthropic API | `anthropic/model` | `anthropic/<model-name>` |
158
+ | Form | Meaning | Credentials used |
159
+ |------|---------|-------------------|
160
+ | `provider/model` (bare, canonical) | Policy-routed, direct-first | Direct provider key if configured, else `OPENROUTER_API_KEY` |
161
+ | `openrouter/provider/model` | Explicit force-OpenRouter override (required for gateway-only vendors) | `OPENROUTER_API_KEY`, always |
155
162
 
156
- **Important:** The model name format tells the SDK which authentication to use:
157
- - `openrouter/...` → Uses OpenRouter API key from auth.json
158
- - `google/...` → Uses `GOOGLE_GENERATIVE_AI_API_KEY` environment variable
159
- - `openai/...` Uses `OPENAI_API_KEY` environment variable
160
- - `anthropic/...` → Uses `ANTHROPIC_API_KEY` environment variable
163
+ **Important:** the bare form is not tied to one credential — the router decides at launch time:
164
+ - `google/...`, `openai/...`, `anthropic/...`, `deepseek/...` (bare) that vendor's direct key if present, else `OPENROUTER_API_KEY`
165
+ - `openrouter/...` → always `OPENROUTER_API_KEY`
166
+ - `--gateway direct` forces the direct key (errors if it's missing); `--gateway openrouter` forces OpenRouter (errors if that key is missing); omit `--gateway` (or pass `auto`) for the default direct-first behavior described above
161
167
 
162
168
  ---
163
169
 
@@ -252,6 +258,10 @@ amicus start \
252
258
  - `--json`: With `--no-ui`, emit the run result as one stable JSON document on stdout
253
259
  (`schemaVersion: 2`; the `summary` field is the model's output).
254
260
  - `--no-validate-model`: Skip the model-catalog pre-flight check (validation is on by default).
261
+ - `--gateway <mode>`: Routing override for this launch — `auto` (default, direct-first: your
262
+ direct provider key when present, else OpenRouter), `direct` (require a direct provider key,
263
+ errors if missing), or `openrouter` (force OpenRouter, errors if that key is missing). Omit to
264
+ fall back to the `routing.prefer` config default (`"direct"` unless changed).
255
265
  - `--agent <agent>`: Agent mode (controls tool permissions). If omitted, defaults to
256
266
  **Chat** in interactive mode and **Build** in headless (`--no-ui`) mode — `chat`
257
267
  stalls without user interaction, so headless runs need an agent that doesn't wait
@@ -281,17 +291,26 @@ The CLI validates all inputs **before** launching the sidecar. Invalid inputs fa
281
291
  | `--timeout` | Must be positive number | `Error: --timeout must be a positive number` |
282
292
  | `--context-turns` | Must be positive number | `Error: --context-turns must be a positive number` |
283
293
  | `--context-since` | Must match format: `30m`, `2h`, `1d` | `Error: --context-since must be in format: 30m, 2h, or 1d` |
284
- | API Key | Must be set for model's provider | `Error: <KEY_NAME> environment variable is required for <Provider> models` |
294
+ | `--gateway` | If provided, must be one of `auto`, `direct`, `openrouter` | `Error: --gateway must be one of: auto, direct, openrouter` |
295
+ | API Key | The router must be able to resolve *some* usable key for the requested model (see below) | Reason sentence + fix hint, e.g. `No API key was found for this vendor via any gateway.` / `Add a provider key or an OpenRouter key.` |
285
296
 
286
297
  **API Key Requirements by Provider:**
287
298
 
288
- | Model Prefix | Required Env Var | Example |
289
- |--------------|------------------|---------|
290
- | `openrouter/...` | `OPENROUTER_API_KEY` | `export OPENROUTER_API_KEY=sk-or-...` |
291
- | `google/...` | `GOOGLE_GENERATIVE_AI_API_KEY` | `export GOOGLE_GENERATIVE_AI_API_KEY=...` |
292
- | `openai/...` | `OPENAI_API_KEY` | `export OPENAI_API_KEY=sk-...` |
293
- | `anthropic/...` | `ANTHROPIC_API_KEY` | `export ANTHROPIC_API_KEY=sk-ant-...` |
294
- | `deepseek/...` | `DEEPSEEK_API_KEY` | `export DEEPSEEK_API_KEY=...` |
299
+ Bare canonical ids (`google/...`, `openai/...`, `anthropic/...`, `deepseek/...`) are policy-routed
300
+ the direct key for that vendor if configured, else `OPENROUTER_API_KEY` as fallback. Only
301
+ `openrouter/...` (the explicit override) and gateway-only vendors (Qwen, Grok, Mistral, GLM, …)
302
+ strictly require `OPENROUTER_API_KEY`:
303
+
304
+ | Model Prefix | Env Var Amicus Will Use | Notes |
305
+ |--------------|--------------------------|-------|
306
+ | `openrouter/...` | `OPENROUTER_API_KEY` | Always required for this form |
307
+ | `google/...` (bare) | `GOOGLE_GENERATIVE_AI_API_KEY`, else `OPENROUTER_API_KEY` | Errors only if neither is set |
308
+ | `openai/...` (bare) | `OPENAI_API_KEY`, else `OPENROUTER_API_KEY` | Errors only if neither is set |
309
+ | `anthropic/...` (bare) | `ANTHROPIC_API_KEY`, else `OPENROUTER_API_KEY` | Errors only if neither is set |
310
+ | `deepseek/...` (bare) | `DEEPSEEK_API_KEY`, else `OPENROUTER_API_KEY` | Errors only if neither is set |
311
+
312
+ `--gateway direct` narrows this to "the direct key specifically" (errors if only OpenRouter is
313
+ configured); `--gateway openrouter` narrows it to `OPENROUTER_API_KEY` specifically.
295
314
 
296
315
  **Handling validation errors:**
297
316
 
@@ -310,8 +329,8 @@ amicus start --model gemini --prompt "Task" --agent Build
310
329
  # Fix: Provide a non-empty briefing
311
330
  amicus start --model gemini --prompt "Detailed task description"
312
331
 
313
- # Error: OPENROUTER_API_KEY environment variable is required
314
- # Fix: Set the API key for your provider
332
+ # Error: No API key was found for this vendor via any gateway.
333
+ # Fix: add a direct provider key or an OpenRouter key
315
334
  export OPENROUTER_API_KEY=sk-or-your-key
316
335
  amicus start --model gemini --prompt "Task"
317
336
  ```
@@ -325,10 +344,11 @@ amicus fanout --models "gemini,gpt,deepseek" --prompt-file ./briefing.md --json
325
344
  Runs the same prompt on every listed model in parallel (one shared engine server, headless),
326
345
  then prints ONE JSON wave document: `status` (`complete`|`partial`|`error`), `counts`, and
327
346
  `legs[]` where each leg's `summary` is that model's answer. Exit codes: 0 complete, 2 partial,
328
- 1 error/aborted. Aliases and full `provider/model` IDs both work; duplicates are allowed
347
+ 1 error/aborted. Aliases and full `provider/model` IDs both work bare canonical ids route
348
+ direct-first, `openrouter/provider/model` forces OpenRouter; duplicates are allowed
329
349
  (distinct legs). `--wave-id <id>` pins leg IDs to `<id>-1..N`. Shared per-leg knobs: `--agent`,
330
350
  `--thinking`, `--timeout`, `--summary-length`, `--no-context`, `--context-*`, `--mcp*`,
331
- `--no-validate-model`, `--cwd`.
351
+ `--no-validate-model`, `--gateway`, `--cwd`.
332
352
 
333
353
  ### Inspect the Model Catalog
334
354
 
@@ -386,7 +406,10 @@ Starts a **new** sidecar session that inherits the old session's conversation as
386
406
  - `--prompt`: New task description for the continuation
387
407
 
388
408
  **Optional:**
389
- - `--model <model>`: Override model (defaults to original session's model)
409
+ - `--model <model>`: Override model (defaults to original session's model) — bare canonical ids
410
+ route direct-first; `openrouter/provider/model` forces OpenRouter
411
+ - `--gateway <mode>`: Routing override for the new session (`auto`|`direct`|`openrouter`), same
412
+ semantics as `start`. Only meaningful when `--model` is also given
390
413
  - `--context-turns <N>`: Max turns from previous session to include (default: 50)
391
414
  - `--context-max-tokens <N>`: Max tokens for context (default: 80000)
392
415
  - `--no-ui`: Run in autonomous mode
@@ -427,7 +450,9 @@ Use short aliases (run `amicus models` to see the live catalog, and `amicus mode
427
450
  - `--model deepseek` -- DeepSeek
428
451
  - Omit `--model` entirely to use your configured default
429
452
 
430
- Full model strings also work: `--model openrouter/provider/model-id`
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
455
+ force-OpenRouter override).
431
456
 
432
457
  ### Verifying Model Names
433
458
 
@@ -891,8 +916,8 @@ echo $ANTHROPIC_API_KEY # For Anthropic models
891
916
  ### "401 Unauthorized" or authentication errors
892
917
 
893
918
  1. Verify you're using the correct model name format:
894
- - OpenRouter models: `openrouter/provider/model`
895
- - Direct API models: `provider/model`
919
+ - Bare canonical (policy-routed, direct-first): `provider/model`
920
+ - Explicit force-OpenRouter override: `openrouter/provider/model`
896
921
 
897
922
  2. Check your API key is valid and has credits
898
923
 
@@ -967,11 +992,14 @@ amicus start --model ... --prompt "..." --agent Explore
967
992
  amicus start --model ... --prompt "..." --agent MyCustomAgent
968
993
  ```
969
994
 
970
- **"Error: <KEY_NAME> environment variable is required for <Provider> models"**
995
+ **"No API key was found for this vendor via any gateway." / "No API key is configured for this vendor's direct API."**
971
996
 
972
- The API key for the model's provider is not set:
997
+ A bare canonical id needs *some* usable key for the vendor either its direct key or
998
+ `OPENROUTER_API_KEY` as a fallback. `--gateway direct` narrows that to "the direct key
999
+ specifically" (fails if only OpenRouter is configured); `--gateway openrouter` narrows it to
1000
+ `OPENROUTER_API_KEY` specifically:
973
1001
  ```bash
974
- # For OpenRouter models (openrouter/...)
1002
+ # OPENROUTER_API_KEY is the fallback for every direct-capable vendor
975
1003
  export OPENROUTER_API_KEY=sk-or-your-key
976
1004
 
977
1005
  # For Google models (google/...)
@@ -10,9 +10,10 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { resolveModelFromArgs, validateFallbackModel } = require('./utils/start-helpers');
13
+ const { resolveLaunchModel } = require('./utils/start-helpers');
14
14
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
15
15
  const { requireNoUiForJson, requireValidTaskId } = require('./utils/cli-preflight');
16
+ const { GATEWAY_MODES } = require('./utils/model-descriptor');
16
17
 
17
18
  /**
18
19
  * Handle 'amicus resume' command
@@ -70,11 +71,35 @@ async function handleContinue(args) {
70
71
 
71
72
  requireNoUiForJson(args, useJson);
72
73
 
73
- // F5: an explicitly passed --model gets the same resolution+validation as start.
74
+ // FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
75
+ // --gateway via validateStartArgs (cli.js) — continue never did, so a
76
+ // typo'd value silently fell through to resolveGatewayMode's pass-through
77
+ // instead of failing fast with a clear error. Checked unconditionally
78
+ // (not just when --model is also given) since --gateway alone is still a
79
+ // user-facing typo worth catching.
80
+ if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
81
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` }));
82
+ }
83
+
84
+ // F5/#61 Task 7.3: an explicitly passed --model routes through the gateway
85
+ // router exactly like start (resolveLaunchModel), so --gateway / direct-first
86
+ // policy / structured route errors apply here too. The NO-`--model` case
87
+ // (inherit the prior session's model) is unchanged below — that IS the
88
+ // "preserve prior route" behavior: the prior concrete id is reused verbatim,
89
+ // never re-resolved.
90
+ // #61 Task 5.2 (best-effort provenance): stash the freshly-resolved
91
+ // gateway/resolutionVersion so the NEW session's metadata can record them.
92
+ // These stay undefined on the inherit-prior-model path (no fresh routing
93
+ // happened there), so the metadata writer simply omits them — the prior
94
+ // concrete model id being reused verbatim already prevents a silent gateway
95
+ // change for a continued session without a fresh --model.
96
+ let routeGateway;
97
+ let routeResolutionVersion;
74
98
  if (args.model !== undefined) {
75
- const { model, alias } = resolveModelFromArgs(args);
99
+ const { model, gateway, provenance } = await resolveLaunchModel(args);
76
100
  args.model = model;
77
- args.model = await validateFallbackModel(args, alias);
101
+ routeGateway = gateway;
102
+ routeResolutionVersion = provenance && provenance.resolutionVersion;
78
103
  }
79
104
 
80
105
  const { continueAmicus } = require('./index');
@@ -91,6 +116,8 @@ async function handleContinue(args) {
91
116
  headless: args['no-ui'],
92
117
  timeout: args.timeout,
93
118
  json: useJson,
119
+ gateway: routeGateway,
120
+ resolutionVersion: routeResolutionVersion,
94
121
  });
95
122
  } catch (err) {
96
123
  // Same rationale as handleResume above: continueSidecar/loadPreviousSession
@@ -14,9 +14,10 @@
14
14
 
15
15
  const { validateStartArgs } = require('./cli');
16
16
  const { validateTaskId } = require('./utils/validators');
17
- const { resolveModelFromArgs, validateFallbackModel } = require('./utils/start-helpers');
17
+ const { resolveLaunchModel } = require('./utils/start-helpers');
18
18
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
19
19
  const { requireNoUiForJson } = require('./utils/cli-preflight');
20
+ const { GATEWAY_MODES } = require('./utils/model-descriptor');
20
21
 
21
22
  /**
22
23
  * Handle 'sidecar start' command
@@ -43,9 +44,8 @@ async function handleStart(args) {
43
44
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
44
45
  }
45
46
 
46
- const { model, alias } = resolveModelFromArgs(args);
47
+ const { model, alias } = await resolveLaunchModel(args);
47
48
  args.model = model;
48
- args.model = await validateFallbackModel(args, alias);
49
49
 
50
50
  // Normalize agent: --agent takes precedence, otherwise use --mode
51
51
  args.agent = args.agent || args.mode;
@@ -108,6 +108,14 @@ async function handleStart(args) {
108
108
  async function handleFanout(args) {
109
109
  const useJson = !!args.json;
110
110
 
111
+ // FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
112
+ // --gateway via validateStartArgs (cli.js) — fanout never did, so a typo'd
113
+ // value silently fell through to resolveGatewayMode's pass-through instead
114
+ // of failing fast with a clear error.
115
+ if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
116
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` }));
117
+ }
118
+
111
119
  const { resolvePromptSource } = require('./utils/prompt-source');
112
120
  const promptRes = resolvePromptSource(args);
113
121
  if (promptRes.error) {
@@ -161,7 +169,7 @@ async function handleFanout(args) {
161
169
 
162
170
  // Direct require — the src/index.js public re-export is added later (Task 13)
163
171
  const { runFanout } = require('./sidecar/fanout');
164
- const { loadConfig } = require('./utils/config');
172
+ const { loadConfig, resolveGatewayMode } = require('./utils/config');
165
173
  const cfg = loadConfig() || {};
166
174
  const { exitCode } = await runFanout({
167
175
  models: args.models,
@@ -187,6 +195,9 @@ async function handleFanout(args) {
187
195
  noMcp: args['no-mcp'],
188
196
  excludeMcp: args['exclude-mcp'],
189
197
  noValidateModel: args['no-validate-model'],
198
+ // #61 Task 7.3: --gateway merged with routing.prefer, applied per leg
199
+ // by validateFanoutModels' router call.
200
+ gatewayMode: resolveGatewayMode(args.gateway),
190
201
  json: !!args.json,
191
202
  client: args.client,
192
203
  maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,