amicus 2.2.0 → 3.1.0

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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +13 -8
  4. package/bin/amicus.js +5 -0
  5. package/electron/close-guard.js +4 -4
  6. package/electron/fold.js +8 -8
  7. package/electron/ipc-guard.js +3 -3
  8. package/electron/main.js +31 -31
  9. package/electron/opencode-theme.js +3 -3
  10. package/electron/preload-content.js +1 -1
  11. package/electron/setup-ui.js +27 -3
  12. package/package.json +4 -3
  13. package/skills/second-opinion/SKILL.md +2 -0
  14. package/skills/sidecar/SKILL.md +66 -38
  15. package/src/cli-handlers-resume-continue.js +31 -4
  16. package/src/cli-handlers-run.js +15 -4
  17. package/src/cli.js +13 -0
  18. package/src/mcp-server.js +99 -12
  19. package/src/mcp-tools.js +26 -4
  20. package/src/opencode-client.js +18 -2
  21. package/src/sidecar/continue.js +10 -3
  22. package/src/sidecar/electron-install.js +9 -8
  23. package/src/sidecar/fanout-leg.js +26 -1
  24. package/src/sidecar/fanout-output.js +5 -0
  25. package/src/sidecar/fanout-validate.js +81 -0
  26. package/src/sidecar/fanout.js +65 -77
  27. package/src/sidecar/session-utils.js +6 -0
  28. package/src/sidecar/setup.js +2 -1
  29. package/src/utils/alias-resolver.js +6 -35
  30. package/src/utils/api-key-store.js +1 -9
  31. package/src/utils/auth-json.js +1 -1
  32. package/src/utils/config.js +98 -16
  33. package/src/utils/curated-models.js +33 -4
  34. package/src/utils/gateway-router.js +115 -0
  35. package/src/utils/input-validators.js +12 -42
  36. package/src/utils/model-classification.js +65 -0
  37. package/src/utils/model-descriptor.js +72 -0
  38. package/src/utils/model-fetcher.js +35 -9
  39. package/src/utils/model-input-default.js +32 -0
  40. package/src/utils/model-validator.js +68 -84
  41. package/src/utils/node-version-guard.js +16 -0
  42. package/src/utils/provider-registry.js +57 -0
  43. package/src/utils/quick-picks.js +11 -3
  44. package/src/utils/result-schema-rebuild.js +98 -0
  45. package/src/utils/result-schema.js +6 -76
  46. package/src/utils/route-error.js +137 -0
  47. package/src/utils/route-launch.js +179 -0
  48. package/src/utils/start-helpers.js +96 -43
  49. package/src/utils/validators.js +1 -8
@@ -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,
package/src/cli.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  } = require('./utils/validators');
19
19
  const { resolvePromptSource } = require('./utils/prompt-source');
20
20
  const { logger } = require('./utils/logger');
21
+ const { GATEWAY_MODES } = require('./utils/model-descriptor');
21
22
 
22
23
  /**
23
24
  * Default values per spec §4.1
@@ -245,6 +246,15 @@ function validateStartArgs(args) {
245
246
  }
246
247
  }
247
248
 
249
+ // Validate --gateway (if provided) — #61 Task 7.1. resolveGatewayMode()
250
+ // already treats any non-'direct'/'openrouter' string as a pass-through
251
+ // (effectively silent auto fallback), so this pre-flight check exists
252
+ // purely to catch typos with a clear error instead of letting them slip
253
+ // through unnoticed.
254
+ if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
255
+ return { valid: false, error: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` };
256
+ }
257
+
248
258
  // Validate MCP spec format (if provided)
249
259
  const mcpCheck = validateMcpSpec(args.mcp);
250
260
  if (!mcpCheck.valid) {
@@ -401,6 +411,7 @@ Options for 'start':
401
411
  --exclude-mcp <name> Exclude specific MCP server (repeatable)
402
412
  --validate-model (Deprecated: validation is on by default)
403
413
  --no-validate-model Skip model-catalog validation before launch
414
+ --gateway <mode> Routing: auto (direct-first), direct, or openrouter
404
415
  --position <pos> Window position: right (default), left, center
405
416
  `,
406
417
  fanout: `
@@ -415,6 +426,7 @@ Options for 'fanout':
415
426
  --json Emit the wave result as stable JSON on stdout
416
427
  --max-cost <$> Refuse the wave if the estimated total exceeds $ (soft ceiling)
417
428
  --no-cost-gate Disable the budget gate (per-$/Mtok threshold + ceiling) for this run
429
+ --gateway <mode> Routing: auto (direct-first), direct, or openrouter
418
430
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
419
431
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
420
432
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
@@ -456,6 +468,7 @@ Options for 'continue':
456
468
  <task_id> Required. Session to build on (positional)
457
469
  --prompt <text> Required. Briefing for the new session
458
470
  --model <model> Optional. Override the model (alias or provider/model)
471
+ --gateway <mode> Routing when --model is given: auto (direct-first), direct, or openrouter
459
472
  --cwd <path> Project directory (default: cwd)
460
473
  --no-ui Run without GUI (autonomous mode)
461
474
  --json With --no-ui: emit the run result as stable JSON
package/src/mcp-server.js CHANGED
@@ -4,7 +4,6 @@ const path = require('path');
4
4
  const { writeFileAtomic } = require('./utils/atomic-write');
5
5
  const { spawn } = require('child_process');
6
6
  const { getTools, getGuideText } = require('./mcp-tools');
7
- const { tryResolveModel } = require('./utils/config');
8
7
  const os = require('os');
9
8
  const { logger } = require('./utils/logger');
10
9
  const { safeSessionDir } = require('./utils/validators');
@@ -241,7 +240,7 @@ function spawnSidecarProcess(args, sessionDir) {
241
240
  /** Tool handler implementations */
242
241
  const handlers = {
243
242
  async amicus_start(input, project, mcpServer) {
244
- // Validate all inputs before any session creation
243
+ // Validate non-model inputs (prompt/timeout/agent) before any session creation.
245
244
  const { validateStartInputs } = require('./utils/input-validators');
246
245
  const validation = validateStartInputs(input);
247
246
  if (!validation.valid) {
@@ -250,7 +249,40 @@ const handlers = {
250
249
  content: [{ type: 'text', text: JSON.stringify(validation.error) }],
251
250
  };
252
251
  }
253
- const resolvedModel = validation.resolvedModel;
252
+
253
+ // Model routing (#61 Task 6.2): route through the gateway router for MCP
254
+ // parity with the CLI's resolveLaunchModel (start-helpers.js). Unlike the
255
+ // CLI, this handler must never process.exit — the MCP server is long-lived
256
+ // and serves many tool calls — so a routing failure returns a structured
257
+ // model_route_error response instead.
258
+ //
259
+ // Default resolution mirrors resolveLaunchModel: an omitted input.model
260
+ // falls back to the configured default before hitting the router, so
261
+ // "no model, no default" produces a clean invalid_descriptor structured
262
+ // error (via the router) rather than a crash. Shared with the CLI's
263
+ // resolveLaunchModel (start-helpers.js) via model-input-default.js.
264
+ const { resolveGatewayMode } = require('./utils/config');
265
+ const { resolveRouteForLaunch } = require('./utils/route-launch');
266
+ const { toStructuredError } = require('./utils/route-error');
267
+ const { resolveModelInputOrDefault } = require('./utils/model-input-default');
268
+
269
+ const modelInput = resolveModelInputOrDefault(input.model);
270
+ // input.gateway is now exposed by the MCP tool schema (#61 Task 7.2);
271
+ // resolveGatewayMode(undefined) falls back to config routing.prefer / 'auto'.
272
+ const routeResult = await resolveRouteForLaunch({
273
+ model: modelInput,
274
+ gatewayMode: resolveGatewayMode(input.gateway),
275
+ source: 'mcp',
276
+ allowSelection: false,
277
+ validateModel: true,
278
+ });
279
+ if (routeResult.kind !== 'resolved') {
280
+ return {
281
+ isError: true,
282
+ content: [{ type: 'text', text: JSON.stringify(toStructuredError(routeResult)) }],
283
+ };
284
+ }
285
+ const resolvedModel = routeResult.executableId;
254
286
 
255
287
  const cwd = project || getProjectDir(input.project);
256
288
  const { generateTaskId } = require('./sidecar/start');
@@ -268,7 +300,9 @@ const handlers = {
268
300
  const briefingPath = path.join(sessionDir, 'briefing.md');
269
301
  const detectedClient = detectClient(mcpServer);
270
302
  const args = ['start', '--prompt-file', briefingPath, '--task-id', taskId, '--client', detectedClient];
271
- if (resolvedModel) { args.push('--model', resolvedModel); }
303
+ // resolvedModel is always defined here — a routing failure already
304
+ // returned above — and is the router's executableId, not the raw alias.
305
+ args.push('--model', resolvedModel);
272
306
  const agent = (input.noUi && (!input.agent || input.agent.toLowerCase() === 'chat'))
273
307
  ? 'build' : input.agent;
274
308
  if (agent) { args.push('--agent', agent); }
@@ -296,7 +330,8 @@ const handlers = {
296
330
  const { runHeadless } = require('./headless');
297
331
  const { generateFoldNonce } = require('./utils/fold-marker');
298
332
  const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
299
- // resolvedModel is already available from validateStartInputs() above
333
+ // resolvedModel (the router's executableId) is already available from
334
+ // the model-routing step above
300
335
 
301
336
  // #47: the shared OpenCode server is shared across projects, so the
302
337
  // session must be created scoped to the resolved project directory
@@ -441,7 +476,15 @@ const handlers = {
441
476
  taskId, status: 'running', mode: 'headless',
442
477
  message: 'Amicus started in headless mode. Use amicus_status to check progress.',
443
478
  });
444
- return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
479
+ // FIX 2 (#61 whole-branch review): surface the router's one-shot
480
+ // migration notice here too — resolveRouteForLaunch already burned
481
+ // the migration_notified flag for this vendor when it built
482
+ // routeResult above, so this is the only chance to show it on the
483
+ // shared-server path (no CLI stderr exists for an MCP caller).
484
+ const sharedServerContent = [{ type: 'text', text: body }];
485
+ if (routeResult.notice) { sharedServerContent.push({ type: 'text', text: routeResult.notice }); }
486
+ sharedServerContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
487
+ return { content: sharedServerContent };
445
488
  } catch (err) {
446
489
  logger.warn('Shared server path failed, falling back to spawn', { error: err.message });
447
490
  // Clean up partial shared server state before falling through
@@ -490,10 +533,16 @@ const handlers = {
490
533
  'Then wait for the user to tell you. Use amicus_read to get results once they confirm.';
491
534
 
492
535
  const body = JSON.stringify({ taskId, status: 'running', mode, message });
536
+ // FIX 2 (#61 whole-branch review): the spawn path never touches stderr of
537
+ // the CLI child that will do the routing print — this handler already
538
+ // resolved the route in-process above, so surface its notice here.
539
+ const spawnContent = [{ type: 'text', text: body }];
540
+ if (routeResult.notice) { spawnContent.push({ type: 'text', text: routeResult.notice }); }
493
541
  if (isHeadless) {
494
- return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
542
+ spawnContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
543
+ return { content: spawnContent };
495
544
  }
496
- return textResult(body);
545
+ return { content: spawnContent };
497
546
  },
498
547
 
499
548
  async amicus_status(input, project) {
@@ -795,11 +844,37 @@ const handlers = {
795
844
  },
796
845
 
797
846
  async amicus_continue(input, project, mcpServer) {
847
+ // Model routing parity with amicus_start (#61 whole-branch review FIX 3,
848
+ // Task 6.2 follow-up): an explicit --model on continue used to be
849
+ // pre-checked only with the legacy tryResolveModel (alias-existence-only),
850
+ // so an unroutable model (e.g. a gateway-only vendor with no OpenRouter
851
+ // key) spawned a child that then died opaquely trying to launch it.
852
+ // Route it through the SAME gateway router amicus_start uses instead, and
853
+ // return the same structured model_route_error on failure — never spawn.
854
+ // The no-`--model` inherit-prior-session-model path is UNCHANGED: nothing
855
+ // below runs, and handleContinue reuses the prior concrete model verbatim.
856
+ let resolvedModel;
798
857
  if (input.model) {
799
- const modelCheck = tryResolveModel(input.model);
800
- if (modelCheck.error) {
801
- return textResult(modelCheck.error, true);
858
+ const { resolveGatewayMode } = require('./utils/config');
859
+ const { resolveRouteForLaunch } = require('./utils/route-launch');
860
+ const { toStructuredError } = require('./utils/route-error');
861
+ const { resolveModelInputOrDefault } = require('./utils/model-input-default');
862
+
863
+ const modelInput = resolveModelInputOrDefault(input.model);
864
+ const routeResult = await resolveRouteForLaunch({
865
+ model: modelInput,
866
+ gatewayMode: resolveGatewayMode(input.gateway),
867
+ source: 'mcp',
868
+ allowSelection: false,
869
+ validateModel: true,
870
+ });
871
+ if (routeResult.kind !== 'resolved') {
872
+ return {
873
+ isError: true,
874
+ content: [{ type: 'text', text: JSON.stringify(toStructuredError(routeResult)) }],
875
+ };
802
876
  }
877
+ resolvedModel = routeResult.executableId;
803
878
  }
804
879
 
805
880
  const cwd = project || getProjectDir(input.project);
@@ -814,7 +889,15 @@ const handlers = {
814
889
  const briefingPath = path.join(sessionDir, 'briefing.md');
815
890
  const args = ['continue', input.taskId, '--prompt-file', briefingPath,
816
891
  '--task-id', newTaskId, '--client', detectClient(mcpServer), '--cwd', cwd];
817
- if (input.model) { args.push('--model', input.model); }
892
+ // resolvedModel is the router's executableId (never the raw alias) — a
893
+ // routing failure already returned above, so this is only reached when
894
+ // input.model was absent (resolvedModel stays undefined) or resolved.
895
+ if (resolvedModel) { args.push('--model', resolvedModel); }
896
+ // #61 Task 7.3: forward the caller's gateway preference to the spawned
897
+ // CLI child too — handleContinue still does its OWN routing (it's the
898
+ // one wiring --model/--gateway into a resolved session), this just keeps
899
+ // the child's own resolution consistent with what was already decided.
900
+ if (input.gateway) { args.push('--gateway', input.gateway); }
818
901
  if (input.noUi) { args.push('--no-ui', '--agent', 'build'); }
819
902
  if (input.timeout) { args.push('--timeout', String(input.timeout)); }
820
903
  if (input.contextTurns) { args.push('--context-turns', String(input.contextTurns)); }
@@ -955,6 +1038,10 @@ const handlers = {
955
1038
  ];
956
1039
  const agent = input.agent || 'Build';
957
1040
  args.push('--agent', agent);
1041
+ // #61 Task 7.3: forward the caller's gateway preference to the spawned
1042
+ // CLI child, which routes each leg's model (piece 2) — unlike
1043
+ // amicus_start, this handler never resolves any leg's route itself.
1044
+ if (input.gateway) { args.push('--gateway', input.gateway); }
958
1045
  if (input.thinking) { args.push('--thinking', input.thinking); }
959
1046
  if (input.timeout) { args.push('--timeout', String(input.timeout)); }
960
1047
  if (input.summaryLength) { args.push('--summary-length', input.summaryLength); }
package/src/mcp-tools.js CHANGED
@@ -10,6 +10,7 @@
10
10
  const { z } = require('zod');
11
11
  const { formatAliasNames } = require('./utils/config');
12
12
  const { READ_CAP_BYTES } = require('./utils/read-slice');
13
+ const { GATEWAY_MODES } = require('./utils/model-descriptor');
13
14
 
14
15
  /** Zod pattern for safe task IDs (alphanumeric, hyphens, underscores only) */
15
16
  const safeTaskId = z.string().regex(
@@ -55,9 +56,15 @@ function getTools() {
55
56
  ' Pass includeContext: false when the briefing is fully self-contained.',
56
57
  inputSchema: {
57
58
  model: safeModel.optional().describe(
58
- `Short alias (${aliasNames}) or full provider/model ID. ` +
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; ' +
61
+ 'openrouter/provider/model forces OpenRouter). ' +
59
62
  'If omitted, uses the configured default. Call amicus_guide to see all aliases.'
60
63
  ),
64
+ gateway: z.enum(GATEWAY_MODES).optional().describe(
65
+ 'Routing preference: auto (direct-first, default), direct (require a ' +
66
+ 'direct provider key), or openrouter (force OpenRouter).'
67
+ ),
61
68
  prompt: z.string().describe(
62
69
  'Detailed task briefing. Include: objective, background, ' +
63
70
  'files of interest, success criteria.'
@@ -251,7 +258,13 @@ function getTools() {
251
258
  'New task description for the continuation.'
252
259
  ),
253
260
  model: safeModel.optional().describe(
254
- `Override model — short alias (${aliasNames}) or full provider/model ID. Defaults to the original session's model.`
261
+ `Override model — short alias (${aliasNames}) or full model ID. Bare provider/model routes ` +
262
+ 'direct-first (canonical); openrouter/provider/model forces OpenRouter. Defaults to the ' +
263
+ "original session's model."
264
+ ),
265
+ gateway: z.enum(GATEWAY_MODES).optional().describe(
266
+ 'Routing preference: auto (direct-first, default), direct (require a ' +
267
+ 'direct provider key), or openrouter (force OpenRouter).'
255
268
  ),
256
269
  noUi: z.boolean().optional().default(false).describe(
257
270
  'Run headless. Default false (opens Electron window).'
@@ -308,13 +321,19 @@ function getTools() {
308
321
  'inside). Each leg is also an ordinary session readable by taskId.',
309
322
  inputSchema: {
310
323
  models: z.array(safeModel).min(1).max(10).optional().describe(
311
- `1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
324
+ `1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full model IDs ` +
325
+ 'bare provider/model routes direct-first (canonical), openrouter/provider/model forces ' +
326
+ "OpenRouter. Duplicates allowed. Omit when using 'council'."
312
327
  ),
313
328
  council: z.string().optional().describe(
314
329
  "Run a saved council, or a built-in bench ('free', 'budget', 'frontier'), instead of 'models'. " +
315
330
  "Expands to the council's members; a saved council of the same name shadows a built-in. " +
316
331
  'Mutually exclusive with \'models\'.'
317
332
  ),
333
+ gateway: z.enum(GATEWAY_MODES).optional().describe(
334
+ 'Routing preference: auto (direct-first, default), direct (require a ' +
335
+ 'direct provider key), or openrouter (force OpenRouter).'
336
+ ),
318
337
  prompt: z.string().describe(
319
338
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
320
339
  ),
@@ -492,7 +511,10 @@ Include: Objective, Background, Files of interest, Success criteria, Constraints
492
511
  |-------|-------|
493
512
  ${aliasRows}
494
513
 
495
- Or use full IDs in provider/model format (e.g., openrouter/provider/model-id).
514
+ Or use a full model ID. Bare \`provider/model\` (e.g. anthropic/claude-opus-4.8) is the canonical,
515
+ policy-routed form — it routes direct-first (your direct provider key if configured, else
516
+ OpenRouter). \`openrouter/provider/model\` is an explicit override that forces OpenRouter. The
517
+ \`gateway\` param (or \`routing.prefer\` in config.json) controls this per call or globally.
496
518
  Run amicus_setup to configure defaults and add custom aliases.
497
519
 
498
520
  ## Session Matching