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
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Route-launch views (#61 gateway routing integration, Task 4.2).
3
+ *
4
+ * Additive, read-only helpers consumed by Task 4.4's resolveRouteForLaunch
5
+ * (not wired into any launch path yet). Pure-ish: all I/O goes through the
6
+ * stubbable api-key-store / auth-json / model-catalog modules.
7
+ */
8
+ 'use strict';
9
+
10
+ const { readApiKeys } = require('./api-key-store');
11
+ const { readAuthJsonKeys } = require('./auth-json');
12
+ const { KNOWN_PROVIDERS } = require('./provider-registry');
13
+
14
+ /**
15
+ * Per-provider key presence across BOTH sources: env/.env (readApiKeys) and
16
+ * OpenCode's auth.json (readAuthJsonKeys). True if either source has a key
17
+ * for that provider (Foundation carry-forward, Decision 5).
18
+ * @returns {Object<string,boolean>} map of provider id -> key present
19
+ */
20
+ function buildLaunchKeys() {
21
+ const env = readApiKeys(); // {openrouter:bool, google:bool, openai:bool, anthropic:bool, deepseek:bool}
22
+ const authKeys = readAuthJsonKeys(); // {provider:string,...} (only providers with keys)
23
+ const out = {};
24
+ for (const p of KNOWN_PROVIDERS) {
25
+ out[p] = !!env[p] || !!authKeys[p];
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /**
31
+ * Thin wrapper over model-catalog.getCatalogInfo() for route-resolution
32
+ * callers that only need the models list and the last-refresh error, not the
33
+ * full cache metadata. Never throws: a catalog error resolves to an empty
34
+ * list with a sentinel error string.
35
+ * @returns {Promise<{models: Array, lastRefreshError: string|null}>}
36
+ */
37
+ async function getRouteCatalogInfo() {
38
+ // Lazy-required so jest.doMock('./model-catalog', ...) can intercept it
39
+ // per-test, matching the pattern model-catalog.js itself uses for its deps.
40
+ const { getCatalogInfo } = require('./model-catalog');
41
+ try {
42
+ const info = await getCatalogInfo();
43
+ return { models: info.models || [], lastRefreshError: info.lastRefreshError || null };
44
+ } catch {
45
+ return { models: [], lastRefreshError: 'catalog-unavailable' };
46
+ }
47
+ }
48
+
49
+ /** Module version stamped onto `resolved` results' provenance (carry-forward). */
50
+ const ROUTE_VERSION = 1;
51
+
52
+ /**
53
+ * Build up to ~6 labeled alternatives for a `selection_required` RouteResult
54
+ * (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
55
+ * descriptor plus the live keys/catalogInfo the caller already assembled.
56
+ *
57
+ * Two categories, in order:
58
+ * 1. The SAME model via OpenRouter — only when an OpenRouter key is present
59
+ * AND the OR-namespaced id (`openrouter/<vendor>/<model>`) is actually
60
+ * present in the catalog (never suggest an id we can't confirm exists).
61
+ * 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
62
+ * `<vendor>/`, excluding the requested id itself and excluding any
63
+ * `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
64
+ * only when vendor === 'openrouter' and are filtered out defensively).
65
+ *
66
+ * @param {{vendor?: string, model?: string}} descriptor parsed Descriptor for
67
+ * the request that produced the selection_required (canonical or
68
+ * openrouter-literal — both carry vendor/model)
69
+ * @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
70
+ * @param {{models: Array<{id:string}>}} catalogInfo
71
+ * @returns {Array<{model:string, gateway:string, note:string}>}
72
+ */
73
+ function buildSuggestions(descriptor, keys, catalogInfo) {
74
+ const suggestions = [];
75
+ const vendor = descriptor && descriptor.vendor;
76
+ const model = descriptor && descriptor.model;
77
+ if (!vendor || !model) { return suggestions; }
78
+
79
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
80
+ const requestedDirectId = `${vendor}/${model}`;
81
+
82
+ if (keys && keys.openrouter) {
83
+ const orId = `openrouter/${vendor}/${model}`;
84
+ if (models.some(m => m && m.id === orId)) {
85
+ suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
86
+ }
87
+ }
88
+
89
+ const nsPrefix = `${vendor}/`;
90
+ const sameVendor = models.filter(m =>
91
+ m && typeof m.id === 'string' &&
92
+ m.id.startsWith(nsPrefix) &&
93
+ !m.id.startsWith('openrouter/') &&
94
+ m.id !== requestedDirectId
95
+ ).slice(0, 5);
96
+ for (const m of sameVendor) {
97
+ suggestions.push({ model: m.id, gateway: 'direct', note: `${vendor} model` });
98
+ }
99
+
100
+ return suggestions.slice(0, 6);
101
+ }
102
+
103
+ /**
104
+ * One-time per-vendor notice when auto-routing migrates a both-key holder off
105
+ * OpenRouter onto direct (#61 Task 5.1 — visible-migration guarantee: never
106
+ * silent). Advisory only — never changes the routing decision, and a failed
107
+ * persist (markMigrationNotified is itself best-effort) never blocks the
108
+ * launch. Fires only when ALL of these hold:
109
+ * - the result actually resolved to gateway 'direct'
110
+ * - the caller did NOT explicitly force a gateway (gatewayMode === 'auto');
111
+ * an explicit --gateway direct means the user chose direct, no notice
112
+ * - the descriptor is not itself an explicit `openrouter/...` literal
113
+ * - the user holds an OpenRouter key (otherwise nothing is being migrated
114
+ * FROM)
115
+ * - this vendor hasn't already been notified (getRoutingConfig().migration_notified)
116
+ * @param {{result:object, descriptor:object, gatewayMode:string, keys:object}} args
117
+ * @returns {object} the (possibly mutated) result
118
+ */
119
+ function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
120
+ if (result.kind !== 'resolved' || result.gateway !== 'direct') { return result; }
121
+ if (gatewayMode !== 'auto') { return result; }
122
+ if (descriptor.isExplicitOpenRouter) { return result; }
123
+ if (!keys.openrouter) { return result; }
124
+ try {
125
+ // Lazy-required so jest.doMock('./config', ...) can intercept it per-test.
126
+ const { getRoutingConfig, markMigrationNotified } = require('./config');
127
+ if (getRoutingConfig().migration_notified[descriptor.vendor]) { return result; }
128
+ const notice = `Routing ${descriptor.vendor} via direct API (previously OpenRouter). ` +
129
+ 'Set routing.prefer: "openrouter" (or use --gateway openrouter) to restore.';
130
+ result.notice = result.notice ? `${result.notice} ${notice}` : notice;
131
+ markMigrationNotified(descriptor.vendor);
132
+ } catch (_err) {
133
+ // Advisory only: never let a lookup/persist failure change the routing
134
+ // decision or block the launch.
135
+ }
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * Bridge: alias -> descriptor -> resolveRoute (Task 4.4).
141
+ * Resolves a raw model string to a Descriptor — if it is a known no-slash
142
+ * alias (per getEffectiveAliases()), its concrete id is parsed instead, so an
143
+ * alias pointing at an `openrouter/...` value is treated as an explicit,
144
+ * force-OR literal while an alias pointing at a bare `vendor/model` is
145
+ * policy-routed like any other canonical id. Assembles live key/catalog state
146
+ * and delegates the actual decision to the pure gateway-router. Additive:
147
+ * not wired into any launch path yet.
148
+ * @param {{model:string, gatewayMode:string, source:string, allowSelection?:boolean, validateModel?:boolean}} opts
149
+ * @returns {Promise<object>} RouteResult (resolved | selection_required | error)
150
+ */
151
+ async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelection, validateModel }) {
152
+ // Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router', ...)
153
+ // can intercept them per-test, matching the pattern already used above for model-catalog.
154
+ const { getEffectiveAliases } = require('./config');
155
+ const { parseDescriptor } = require('./model-descriptor');
156
+ const { resolveRoute } = require('./gateway-router');
157
+ const aliases = getEffectiveAliases();
158
+ const concrete = (typeof model === 'string' && !model.includes('/') && aliases[model]) ? aliases[model] : model;
159
+ const descriptor = parseDescriptor(concrete, { aliases });
160
+ const keys = buildLaunchKeys();
161
+ // Skip the catalog fetch entirely under --no-validate-model: gateway-router's
162
+ // catalogGate short-circuits to { ok:true } as soon as validateModel === false,
163
+ // never consulting catalogInfo, so fetching it here would be wasted
164
+ // latency/network (and can hit the network on a cold cache) for no benefit.
165
+ // Strict === false (not just falsy) so this stays in lockstep with catalogGate's
166
+ // own `=== false` guard: any other value (incl. an omitted flag) still fetches,
167
+ // so a caller can never skip the fetch while the gate still classifies against it.
168
+ const catalogInfo = validateModel === false ? { models: [], lastRefreshError: null } : await getRouteCatalogInfo();
169
+ let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo });
170
+ if (result.kind === 'resolved') {
171
+ result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };
172
+ result = maybeMigrationNotice({ result, descriptor, gatewayMode, keys });
173
+ } else if (result.kind === 'selection_required') {
174
+ result.suggestions = buildSuggestions(descriptor, keys, catalogInfo);
175
+ }
176
+ return result;
177
+ }
178
+
179
+ module.exports = { buildLaunchKeys, getRouteCatalogInfo, resolveRouteForLaunch, buildSuggestions, ROUTE_VERSION };
@@ -6,71 +6,124 @@
6
6
  */
7
7
 
8
8
  /**
9
- * Resolve model from args: resolve alias or config default.
10
- * Returns { model, alias } or calls process.exit(1) on error.
9
+ * Derive the alias used for resolution (needed downstream by the budget gate,
10
+ * which reports `alias || args.model` as the pricing lookup's modelInput).
11
+ * Per the #61 Task 4.5 design, only ever returns a no-slash token: an
12
+ * explicit `--model` containing '/' or a slash-bearing config default both
13
+ * resolve to undefined here.
11
14
  * @param {object} args - Parsed CLI arguments
12
- * @returns {{ model: string, alias: string|undefined }}
15
+ * @returns {string|undefined}
13
16
  */
14
- function resolveModelFromArgs(args) {
15
- const { resolveModel, loadConfig } = require('./config');
16
- const rawAlias = args.model;
17
- let model;
18
- try {
19
- model = resolveModel(args.model);
20
- } catch (err) {
21
- console.error(err.message);
22
- process.exit(1);
17
+ function deriveAlias(args) {
18
+ const raw = args.model;
19
+ if (raw !== undefined && raw !== null && !raw.includes('/')) {
20
+ return raw;
23
21
  }
24
-
25
- // Determine the alias used (explicit or config default)
26
- let alias = rawAlias;
27
- if (alias === undefined) {
22
+ if (raw === undefined || raw === null) {
23
+ const { loadConfig } = require('./config');
28
24
  const cfg = loadConfig();
29
25
  if (cfg && cfg.default && !cfg.default.includes('/')) {
30
- alias = cfg.default;
26
+ return cfg.default;
31
27
  }
32
28
  }
33
- return { model, alias };
29
+ return undefined;
34
30
  }
35
31
 
36
32
  /**
37
- * Validate models before launch (F3 #18: default-on).
38
- * --no-validate-model opts out; the old opt-in --validate-model is a no-op kept for back-compat.
39
- * Returns the (possibly corrected) model string.
33
+ * Resolve the model for a launch through the Foundation gateway router (#61
34
+ * Task 4.5), used by every launch path start, continue (Task 7.3), and the
35
+ * MCP amicus_start handler (via the same router, Task 6.2). This replaced the
36
+ * legacy resolveModelFromArgs + validateFallbackModel pair, which was retired
37
+ * once no launch path depended on it (#61 Task 4.7).
38
+ *
39
+ * On `resolved`, returns `{ model, alias, gateway, provenance }` (and prints
40
+ * any advisory `notice` to stderr). On `error` or `selection_required`,
41
+ * renders the appropriate message to stderr and exits(1) — this never
42
+ * returns in that case.
40
43
  * @param {object} args - Parsed CLI arguments
41
- * @param {string|undefined} alias - The alias used for resolution
42
- * @returns {Promise<string>} Validated model string
44
+ * @returns {Promise<{model: string, alias: string|undefined, gateway: string, provenance: object}>}
43
45
  */
44
- async function validateFallbackModel(args, alias) {
45
- // F3 #18: validation is default-on. --no-validate-model opts out; the old
46
- // opt-in --validate-model is now a no-op kept for back-compat.
47
- if (args['no-validate-model']) { return args.model; }
46
+ async function resolveLaunchModel(args) {
47
+ const { resolveGatewayMode } = require('./config');
48
+ const { resolveRouteForLaunch } = require('./route-launch');
49
+ const { toCliMessage, toStructuredError } = require('./route-error');
50
+ const { resolveModelInputOrDefault } = require('./model-input-default');
48
51
 
49
- const headless = args['no-ui'] || !process.stdin.isTTY;
50
- const { detectFallback } = require('./config');
52
+ const gatewayMode = resolveGatewayMode(args.gateway);
53
+ const validateModel = !args['no-validate-model'];
54
+ // Interactive alternatives picker (#61 Task 6.3): only offered on a real
55
+ // interactive TTY session that hasn't opted out with --no-ui. A headless/
56
+ // non-TTY run (CI, piped output, --no-ui) keeps allowSelection false, so a
57
+ // direct miss there still produces the structured error/selection_required
58
+ // rendered below rather than an unhandled prompt.
59
+ const allowSelection = !args['no-ui'] && !!process.stdin.isTTY;
60
+
61
+ // No --model given: the parser does not inject a default, so resolve the
62
+ // configured default here before handing off to the router. Without this, `undefined`
63
+ // would reach resolveRouteForLaunch -> parseDescriptor(undefined) -> an
64
+ // `invalid` result, breaking the common `amicus start` (no --model) case.
65
+ // Shared with the MCP amicus_start handler (mcp-server.js, #61 Task 6.2)
66
+ // via model-input-default.js, so this lookup lives in exactly one place.
67
+ const modelInput = resolveModelInputOrDefault(args.model);
68
+ if (modelInput === undefined) {
69
+ process.stderr.write(
70
+ 'No model specified and no default configured. Run \'amicus setup\' to set a default model.\n'
71
+ );
72
+ process.exit(1);
73
+ }
51
74
 
52
- // Direct-API fallback path: keep the provider-API existence check.
53
- if (alias && detectFallback(alias, args.model)) {
54
- const { validateDirectModel } = require('./model-validator');
75
+ const result = await resolveRouteForLaunch({
76
+ model: modelInput,
77
+ gatewayMode,
78
+ source: 'cli',
79
+ allowSelection,
80
+ validateModel,
81
+ });
82
+
83
+ if (result.kind === 'resolved') {
84
+ if (result.notice) {
85
+ process.stderr.write(`${result.notice}\n`);
86
+ }
87
+ return {
88
+ model: result.executableId,
89
+ alias: deriveAlias(args),
90
+ gateway: result.gateway,
91
+ provenance: result.provenance,
92
+ };
93
+ }
94
+
95
+ // Interactive alternatives picker (#61 Task 6.3): only reachable when
96
+ // allowSelection was true above (real TTY, no --no-ui), so the router only
97
+ // ever hands back selection_required here in that same interactive case.
98
+ if (result.kind === 'selection_required') {
99
+ const { promptRouteSelection } = require('./model-validator');
55
100
  try {
56
- return await validateDirectModel(args.model, alias, { headless });
101
+ const chosen = await promptRouteSelection(result, deriveAlias(args));
102
+ return {
103
+ model: chosen.model,
104
+ alias: deriveAlias(args),
105
+ gateway: chosen.gateway,
106
+ provenance: result.provenance || {},
107
+ };
57
108
  } catch (err) {
58
- console.error(err.message);
109
+ process.stderr.write(`${err.message || 'Model selection cancelled.'}\n`);
59
110
  process.exit(1);
60
111
  }
61
112
  }
62
113
 
63
- // OpenRouter (and any) resolved model: validate against the live catalog.
64
- const { validateAgainstCatalog } = require('./model-validator');
65
- try {
66
- return await validateAgainstCatalog(args.model, alias);
67
- } catch (err) {
68
- console.error(err.message);
69
- process.exit(1);
114
+ // 'error': render and exit. (A non-interactive run never reaches
115
+ // kind:'selection_required' allowSelection is false there, so the router
116
+ // resolves a catalog miss straight to kind:'error' with reason
117
+ // 'model_not_found' instead; see gateway-router.js's catalogGate.)
118
+ if (args.json) {
119
+ process.stderr.write(`${JSON.stringify(toStructuredError(result))}\n`);
120
+ } else {
121
+ process.stderr.write(`${toCliMessage(result)}\n`);
70
122
  }
123
+ process.exit(1);
71
124
  }
72
125
 
73
126
  module.exports = {
74
- resolveModelFromArgs,
75
- validateFallbackModel,
127
+ resolveLaunchModel,
128
+ deriveAlias,
76
129
  };
@@ -19,13 +19,7 @@ const VALID_AGENT_MODES = OPENCODE_AGENTS;
19
19
  /**
20
20
  * Provider to API key mapping
21
21
  */
22
- const PROVIDER_KEY_MAP = {
23
- 'openrouter': { key: 'OPENROUTER_API_KEY', name: 'OpenRouter' },
24
- 'google': { key: 'GOOGLE_GENERATIVE_AI_API_KEY', name: 'Google Gemini' },
25
- 'openai': { key: 'OPENAI_API_KEY', name: 'OpenAI' },
26
- 'anthropic': { key: 'ANTHROPIC_API_KEY', name: 'Anthropic' },
27
- 'deepseek': { key: 'DEEPSEEK_API_KEY', name: 'DeepSeek' },
28
- };
22
+ const { PROVIDER_KEY_MAP } = require('./provider-registry');
29
23
 
30
24
  /** Task ID format: alphanumeric, hyphens, underscores, 1-64 chars */
31
25
  const TASK_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -272,5 +266,4 @@ module.exports = {
272
266
  findSessionInProjectDirs,
273
267
  // Re-exported from input-validators.js
274
268
  validateStartInputs: require('./input-validators').validateStartInputs,
275
- findSimilar: require('./input-validators').findSimilar,
276
269
  };