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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +13 -8
- package/bin/amicus.js +5 -0
- package/electron/close-guard.js +4 -4
- package/electron/fold.js +8 -8
- package/electron/ipc-guard.js +3 -3
- package/electron/main.js +31 -31
- package/electron/opencode-theme.js +3 -3
- package/electron/preload-content.js +1 -1
- package/electron/setup-ui.js +27 -3
- package/package.json +4 -3
- package/skills/second-opinion/SKILL.md +2 -0
- package/skills/sidecar/SKILL.md +66 -38
- package/src/cli-handlers-resume-continue.js +31 -4
- package/src/cli-handlers-run.js +15 -4
- package/src/cli.js +13 -0
- package/src/mcp-server.js +99 -12
- package/src/mcp-tools.js +26 -4
- package/src/opencode-client.js +18 -2
- package/src/sidecar/continue.js +10 -3
- package/src/sidecar/electron-install.js +9 -8
- package/src/sidecar/fanout-leg.js +26 -1
- package/src/sidecar/fanout-output.js +5 -0
- package/src/sidecar/fanout-validate.js +81 -0
- package/src/sidecar/fanout.js +65 -77
- package/src/sidecar/session-utils.js +6 -0
- package/src/sidecar/setup.js +2 -1
- package/src/utils/alias-resolver.js +6 -35
- package/src/utils/api-key-store.js +1 -9
- package/src/utils/auth-json.js +1 -1
- package/src/utils/config.js +98 -16
- package/src/utils/curated-models.js +33 -4
- package/src/utils/gateway-router.js +115 -0
- package/src/utils/input-validators.js +12 -42
- package/src/utils/model-classification.js +65 -0
- package/src/utils/model-descriptor.js +72 -0
- package/src/utils/model-fetcher.js +35 -9
- package/src/utils/model-input-default.js +32 -0
- package/src/utils/model-validator.js +68 -84
- package/src/utils/node-version-guard.js +16 -0
- package/src/utils/provider-registry.js +57 -0
- package/src/utils/quick-picks.js +11 -3
- package/src/utils/result-schema-rebuild.js +98 -0
- package/src/utils/result-schema.js +6 -76
- package/src/utils/route-error.js +137 -0
- package/src/utils/route-launch.js +179 -0
- package/src/utils/start-helpers.js +96 -43
- 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
|
package/skills/sidecar/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
**
|
|
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
|
|
111
|
-
amicus start --model
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
|
150
|
-
|
|
151
|
-
|
|
|
152
|
-
|
|
|
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:**
|
|
157
|
-
- `
|
|
158
|
-
- `
|
|
159
|
-
- `
|
|
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
|
-
|
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
|
294
|
-
|
|
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:
|
|
314
|
-
# Fix:
|
|
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
|
|
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
|
|
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
|
-
-
|
|
895
|
-
-
|
|
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
|
-
**"
|
|
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
|
-
|
|
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
|
-
#
|
|
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 {
|
|
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
|
-
//
|
|
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,
|
|
99
|
+
const { model, gateway, provenance } = await resolveLaunchModel(args);
|
|
76
100
|
args.model = model;
|
|
77
|
-
|
|
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
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
const { validateStartArgs } = require('./cli');
|
|
16
16
|
const { validateTaskId } = require('./utils/validators');
|
|
17
|
-
const {
|
|
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 } =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
542
|
+
spawnContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
|
|
543
|
+
return { content: spawnContent };
|
|
495
544
|
}
|
|
496
|
-
return
|
|
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
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|