amicus 3.0.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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +12 -3
  4. package/electron/setup-ui.js +27 -3
  5. package/package.json +1 -1
  6. package/skills/second-opinion/SKILL.md +2 -0
  7. package/skills/sidecar/SKILL.md +66 -38
  8. package/src/cli-handlers-resume-continue.js +31 -4
  9. package/src/cli-handlers-run.js +15 -4
  10. package/src/cli.js +13 -0
  11. package/src/mcp-server.js +99 -12
  12. package/src/mcp-tools.js +26 -4
  13. package/src/opencode-client.js +18 -2
  14. package/src/sidecar/continue.js +10 -3
  15. package/src/sidecar/fanout-leg.js +26 -1
  16. package/src/sidecar/fanout-output.js +5 -0
  17. package/src/sidecar/fanout-validate.js +81 -0
  18. package/src/sidecar/fanout.js +65 -77
  19. package/src/sidecar/session-utils.js +6 -0
  20. package/src/sidecar/setup.js +2 -1
  21. package/src/utils/alias-resolver.js +6 -35
  22. package/src/utils/api-key-store.js +1 -9
  23. package/src/utils/auth-json.js +1 -1
  24. package/src/utils/config.js +98 -16
  25. package/src/utils/curated-models.js +33 -4
  26. package/src/utils/gateway-router.js +115 -0
  27. package/src/utils/input-validators.js +12 -42
  28. package/src/utils/model-classification.js +65 -0
  29. package/src/utils/model-descriptor.js +72 -0
  30. package/src/utils/model-fetcher.js +35 -9
  31. package/src/utils/model-input-default.js +32 -0
  32. package/src/utils/model-validator.js +68 -84
  33. package/src/utils/provider-registry.js +57 -0
  34. package/src/utils/quick-picks.js +11 -3
  35. package/src/utils/result-schema-rebuild.js +98 -0
  36. package/src/utils/result-schema.js +6 -76
  37. package/src/utils/route-error.js +137 -0
  38. package/src/utils/route-launch.js +179 -0
  39. package/src/utils/start-helpers.js +96 -43
  40. package/src/utils/validators.js +1 -8
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure gateway router (#61). Decides direct vs OpenRouter for a request using
3
+ * only injected state (keys, catalogInfo, gatewayMode) — no I/O. Returns a
4
+ * RouteResult (resolved | selection_required | error). Wiring into launch paths
5
+ * is Plan 2; this module is behavior-neutral until then.
6
+ */
7
+ 'use strict';
8
+
9
+ const { resolved, routeError, selectionRequired, parseDescriptor } = require('./model-descriptor');
10
+ const { classifyModel } = require('./model-classification');
11
+ const { isDirectProvider } = require('./provider-registry');
12
+
13
+ /** Build the executable id for a gateway. */
14
+ function executableFor(gateway, vendor, model) {
15
+ return gateway === 'openrouter' ? `openrouter/${vendor}/${model}` : `${vendor}/${model}`;
16
+ }
17
+
18
+ /**
19
+ * Catalog gate: returns { ok:true, notice? } to proceed, or { ok:false, result }
20
+ * carrying a selection_required/error to return to the caller.
21
+ */
22
+ function catalogGate({ id, gateway, req }) {
23
+ if (req.validateModel === false) {
24
+ return { ok: true, notice: 'Model availability not validated (--no-validate-model).' };
25
+ }
26
+ const verdict = classifyModel(id, gateway, req.catalogInfo);
27
+ if (verdict === 'valid') { return { ok: true }; }
28
+ if (verdict === 'unknown') {
29
+ return { ok: true, notice: `Model '${id}' is unverified against the ${gateway} catalog; attempting anyway.` };
30
+ }
31
+ // invalid
32
+ if (req.allowSelection) {
33
+ return { ok: false, result: selectionRequired({ requested: req.descriptor.raw, suggestions: [] }) };
34
+ }
35
+ return { ok: false, result: routeError({ requested: req.descriptor.raw, reason: 'model_not_found',
36
+ preferredGateway: gateway, suggestions: [] }) };
37
+ }
38
+
39
+ /** Resolve to a concrete gateway after the catalog gate passes. */
40
+ function finish(gateway, vendor, model, req) {
41
+ const id = executableFor(gateway, vendor, model);
42
+ const gate = catalogGate({ id, gateway, req });
43
+ if (!gate.ok) { return gate.result; }
44
+ return resolved({ model: id, gateway, executableId: id,
45
+ provenance: { source: req.source, requested: req.descriptor.raw, gatewayMode: req.gatewayMode }, notice: gate.notice });
46
+ }
47
+
48
+ /**
49
+ * @param {object} req see Task 5 Interfaces
50
+ * @returns RouteResult
51
+ */
52
+ function resolveRoute(req) {
53
+ // 1. Normalize: req.descriptor may arrive as a parsed Descriptor object or a
54
+ // raw canonical/OR-literal id string. Only canonical/openrouter-literal kinds
55
+ // are routable; alias/invalid/garbage descriptors are rejected right here,
56
+ // before any branch below touches vendor/model. All downstream code reads
57
+ // from the normalized `d` (via the cloned `rq`), never from req.descriptor.
58
+ let d = req.descriptor;
59
+ if (typeof d === 'string') {
60
+ d = parseDescriptor(d, { aliases: {} });
61
+ }
62
+ if (!d || (d.kind !== 'canonical' && d.kind !== 'openrouter-literal')) {
63
+ const requested = d ? d.raw : (typeof req.descriptor === 'string' ? req.descriptor : String(req && req.descriptor));
64
+ return routeError({ requested, reason: 'invalid_descriptor', preferredGateway: req.gatewayMode, suggestions: [] });
65
+ }
66
+ const rq = { ...req, descriptor: d };
67
+ const vendor = d.vendor;
68
+ const model = d.model;
69
+
70
+ // 2. Explicit conflict: force-OR literal vs --gateway direct
71
+ if (d.isExplicitOpenRouter && rq.gatewayMode === 'direct') {
72
+ return routeError({ requested: d.raw, reason: 'gateway_conflict', preferredGateway: 'direct', suggestions: [] });
73
+ }
74
+ // 3. Explicit OR literal
75
+ if (d.isExplicitOpenRouter) {
76
+ if (!rq.keys.openrouter) {
77
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
78
+ }
79
+ return finish('openrouter', vendor, model, rq);
80
+ }
81
+ // 4. Gateway-only vendor (no direct integration)
82
+ if (!isDirectProvider(vendor)) {
83
+ if (rq.gatewayMode === 'direct') {
84
+ return routeError({ requested: d.raw, reason: 'no_direct_integration', preferredGateway: 'direct', suggestions: [] });
85
+ }
86
+ if (!rq.keys.openrouter) {
87
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
88
+ }
89
+ return finish('openrouter', vendor, model, rq);
90
+ }
91
+ // 5. Explicit --gateway openrouter
92
+ if (rq.gatewayMode === 'openrouter') {
93
+ if (!rq.keys.openrouter) {
94
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
95
+ }
96
+ return finish('openrouter', vendor, model, rq);
97
+ }
98
+ // 6. Explicit --gateway direct
99
+ if (rq.gatewayMode === 'direct') {
100
+ if (!rq.keys[vendor]) {
101
+ return routeError({ requested: d.raw, reason: 'no_direct_key', preferredGateway: 'direct', suggestions: [] });
102
+ }
103
+ return finish('direct', vendor, model, rq);
104
+ }
105
+ // 7. auto (direct-first)
106
+ if (rq.keys[vendor]) {
107
+ return finish('direct', vendor, model, rq);
108
+ }
109
+ if (rq.keys.openrouter) {
110
+ return finish('openrouter', vendor, model, rq);
111
+ }
112
+ return routeError({ requested: d.raw, reason: 'no_key_for_vendor', preferredGateway: 'direct', suggestions: [] });
113
+ }
114
+
115
+ module.exports = { resolveRoute };
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * @module input-validators
5
5
  * MCP input validation with structured error responses.
6
- * Composes validators from validators.js and adds model resolution.
6
+ * Composes validators from validators.js for prompt/timeout/agent checks.
7
+ * Model resolution/routing lives in mcp-server.js (#61 Task 6.2), not here.
7
8
  */
8
9
 
9
10
  // Lazy require to avoid circular dependency (validators.js re-exports from here)
@@ -13,26 +14,15 @@ function getValidators() {
13
14
  return _validators;
14
15
  }
15
16
 
16
- /**
17
- * Find candidates that start with the input or vice versa.
18
- * @param {string} input
19
- * @param {string[]} candidates
20
- * @returns {string[]} Up to 3 matching candidates
21
- */
22
- function findSimilar(input, candidates) {
23
- if (!input) { return []; }
24
- const lower = input.toLowerCase();
25
- return candidates.filter(c => {
26
- const cl = c.toLowerCase();
27
- return cl.startsWith(lower) || lower.startsWith(cl);
28
- }).slice(0, 3);
29
- }
30
-
31
17
  /**
32
18
  * Validate sidecar_start inputs before session creation.
33
- * Composes existing validators and adds model resolution.
19
+ * Composes existing validators for prompt/timeout/agent. Model resolution is
20
+ * NOT this function's concern (#61 Task 6.2): it is routed separately by the
21
+ * mcp-server.js amicus_start handler via resolveRouteForLaunch, so a routing
22
+ * failure can be rendered as a structured `model_route_error` (parity with the
23
+ * CLI's resolveLaunchModel) rather than the `validation_error` shape below.
34
24
  * @param {Object} input - Raw MCP tool input
35
- * @returns {{ valid: true, resolvedModel: string } | { valid: false, error: Object }}
25
+ * @returns {{ valid: true } | { valid: false, error: Object }}
36
26
  */
37
27
  function validateStartInputs(input) {
38
28
  // 1. Prompt
@@ -49,27 +39,7 @@ function validateStartInputs(input) {
49
39
  };
50
40
  }
51
41
 
52
- // 2. Model: resolve alias to full provider/model string
53
- const { tryResolveModel, getEffectiveAliases } = require('./config');
54
- const { model: resolved, error: modelError } = tryResolveModel(input.model);
55
- if (modelError) {
56
- const aliases = Object.keys(getEffectiveAliases());
57
- const suggestions = findSimilar(input.model, aliases);
58
- return {
59
- valid: false,
60
- error: {
61
- type: 'validation_error',
62
- field: 'model',
63
- message: input.model
64
- ? `Model '${input.model}' not found. ${modelError}`
65
- : `No model specified and no default configured. ${modelError}`,
66
- suggestions,
67
- available: aliases,
68
- },
69
- };
70
- }
71
-
72
- // 3. Timeout: positive number, max 60 minutes
42
+ // 2. Timeout: positive number, max 60 minutes
73
43
  if (input.timeout !== undefined) {
74
44
  const t = Number(input.timeout);
75
45
  if (isNaN(t) || t <= 0) {
@@ -94,7 +64,7 @@ function validateStartInputs(input) {
94
64
  }
95
65
  }
96
66
 
97
- // 4. Agent + headless compatibility
67
+ // 3. Agent + headless compatibility
98
68
  // Note: MCP Zod schema defaults agent to 'Chat'. The handler auto-converts
99
69
  // Chat to Build for headless mode (line ~92 in mcp-server.js). Only reject
100
70
  // if the user explicitly set agent to Chat with noUi (not the Zod default).
@@ -121,7 +91,7 @@ function validateStartInputs(input) {
121
91
  }
122
92
  }
123
93
 
124
- return { valid: true, resolvedModel: resolved };
94
+ return { valid: true };
125
95
  }
126
96
 
127
97
  /**
@@ -175,4 +145,4 @@ function suggestCommand(input, candidates, maxDistance = 2, maxSuggestions = 3)
175
145
  .map(({ c }) => c);
176
146
  }
177
147
 
178
- module.exports = { validateStartInputs, findSimilar, levenshteinDistance, suggestCommand };
148
+ module.exports = { validateStartInputs, levenshteinDistance, suggestCommand };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tri-state catalog classification (#61).
3
+ * Turns the combined catalog + refresh outcome into valid|invalid|unknown for a
4
+ * (model, gateway) pair. `unknown` NEVER rejects a route — it preserves the
5
+ * existing "empty catalog cannot validate, never block a launch" contract.
6
+ *
7
+ * Namespace matching is scoped PER-VENDOR, not by a flat openrouter/direct
8
+ * split. The combined catalog always contains a hardcoded Anthropic floor, so
9
+ * a flat "direct" namespace is never empty even when a specific vendor's rows
10
+ * were never fetched (e.g. that provider's /models call was stale or failed).
11
+ * Matching per-vendor means a missing vendor's rows correctly yield `unknown`
12
+ * instead of being masked by the always-present floor rows of another vendor.
13
+ *
14
+ * - gateway === 'direct': vendor = id.split('/')[0]; namespace prefix is
15
+ * `${vendor}/`, restricted to rows NOT under `openrouter/`.
16
+ * - gateway === 'openrouter': id looks like `openrouter/<vendor>/<model>`, so
17
+ * vendor = id.split('/')[1]; namespace prefix is `openrouter/${vendor}/`.
18
+ *
19
+ * Non-authoritative rows (`authoritative: false`, e.g. the hardcoded Anthropic
20
+ * floor-fallback tagged by model-fetcher.js when a keyed live fetch fails or no
21
+ * key is present — see fetchModelsFromProvider('anthropic', key)) cannot assert
22
+ * absence either: if EVERY row in the matched namespace is non-authoritative and
23
+ * the exact id is not among them, a miss returns `unknown`, not `invalid` — the
24
+ * floor is a stale/synthesized list, not a confirmed model roster, so it must
25
+ * never hard-block a launch (#61 4.3). A namespace containing at least one
26
+ * authoritative (live-fetched) row still yields `invalid` on a genuine miss.
27
+ *
28
+ * Pure: the caller passes catalogInfo (from model-catalog.getCatalogInfo()).
29
+ * @param {string} id exact model id as the user gave it
30
+ * @param {'direct'|'openrouter'} gateway which namespace to match against
31
+ * @param {{models: Array<{id:string, authoritative?: boolean}>, lastRefreshError?: string|null}} catalogInfo
32
+ * @returns {'valid'|'invalid'|'unknown'}
33
+ */
34
+ function classifyModel(id, gateway, catalogInfo) {
35
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
36
+ if (models.length === 0) { return 'unknown'; }
37
+
38
+ const idParts = typeof id === 'string' ? id.split('/') : [];
39
+ const isOpenRouter = gateway === 'openrouter';
40
+ const vendor = isOpenRouter ? idParts[1] : idParts[0];
41
+ const nsPrefix = isOpenRouter ? `openrouter/${vendor}/` : `${vendor}/`;
42
+
43
+ const inNamespace = (mid) => {
44
+ if (typeof mid !== 'string' || !mid.startsWith(nsPrefix)) { return false; }
45
+ return isOpenRouter ? true : !mid.startsWith('openrouter/');
46
+ };
47
+ const namespaceRows = models.filter(m => m && typeof m.id === 'string' && inNamespace(m.id));
48
+
49
+ // No rows for this vendor's namespace -> we cannot assert absence (e.g. that
50
+ // provider's key is absent so its rows were never fetched, or a partial
51
+ // refresh). Unknown — never block on a namespace we couldn't populate.
52
+ if (namespaceRows.length === 0) { return 'unknown'; }
53
+
54
+ const present = namespaceRows.some(m => m.id === id);
55
+ if (present) { return 'valid'; }
56
+
57
+ // Every matched row is a non-authoritative floor-fallback row (never live-
58
+ // fetched) -> a miss cannot be trusted as a confirmed absence. Never block.
59
+ const allNonAuthoritative = namespaceRows.every(m => m.authoritative === false);
60
+ if (allNonAuthoritative) { return 'unknown'; }
61
+
62
+ return 'invalid';
63
+ }
64
+
65
+ module.exports = { classifyModel };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Model-descriptor grammar + RouteResult factories (#61).
3
+ * Pure string classification — no I/O, no provider lookups. The resolver
4
+ * (gateway-router.js) consumes Descriptors and returns RouteResults.
5
+ */
6
+ 'use strict';
7
+
8
+ const GATEWAY_MODES = ['auto', 'direct', 'openrouter'];
9
+ const OR_PREFIX = 'openrouter/';
10
+
11
+ /**
12
+ * Classify a raw model string into a normalized descriptor.
13
+ * Grammar:
14
+ * - `openrouter/<vendor>/<model>` -> openrouter-literal (explicit force-OR)
15
+ * - `<vendor>/<model...>` -> canonical (policy-routed)
16
+ * - known no-slash alias -> alias (resolution deferred to caller)
17
+ * - anything else -> invalid (incl. unknown no-slash token)
18
+ * @param {string} raw
19
+ * @param {{aliases: Object<string,string>}} ctx
20
+ * @returns {{raw:string, kind:string, vendor?:string, model?:string, isExplicitOpenRouter:boolean, error?:string}}
21
+ */
22
+ function parseDescriptor(raw, ctx = {}) {
23
+ const aliases = ctx.aliases || {};
24
+ const trimmed = typeof raw === 'string' ? raw.trim() : '';
25
+ if (!trimmed) {
26
+ return { raw, kind: 'invalid', isExplicitOpenRouter: false, error: 'Empty model identifier' };
27
+ }
28
+ if (trimmed.startsWith(OR_PREFIX)) {
29
+ const rest = trimmed.slice(OR_PREFIX.length);
30
+ const parts = rest.split('/');
31
+ if (parts.length < 2 || !parts[0] || !parts.slice(1).join('/')) {
32
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: true,
33
+ error: `Malformed OpenRouter model id '${trimmed}' (expected openrouter/vendor/model)` };
34
+ }
35
+ return { raw: trimmed, kind: 'openrouter-literal', vendor: parts[0],
36
+ model: parts.slice(1).join('/'), isExplicitOpenRouter: true };
37
+ }
38
+ if (trimmed.includes('/')) {
39
+ const parts = trimmed.split('/');
40
+ if (parts.length < 2 || !parts[0] || !parts.slice(1).join('/')) {
41
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: false,
42
+ error: `Malformed model id '${trimmed}' (expected vendor/model)` };
43
+ }
44
+ return { raw: trimmed, kind: 'canonical', vendor: parts[0],
45
+ model: parts.slice(1).join('/'), isExplicitOpenRouter: false };
46
+ }
47
+ if (Object.prototype.hasOwnProperty.call(aliases, trimmed)) {
48
+ return { raw: trimmed, kind: 'alias', isExplicitOpenRouter: false };
49
+ }
50
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: false,
51
+ error: `Unknown model alias '${trimmed}'. Run 'amicus setup' to configure aliases, or use a vendor/model id.` };
52
+ }
53
+
54
+ /** @returns {{kind:'resolved', model:string, gateway:string, executableId:string, provenance:object, notice?:string}} */
55
+ function resolved({ model, gateway, executableId, provenance, notice }) {
56
+ const out = { kind: 'resolved', model, gateway, executableId, provenance: provenance || {} };
57
+ if (notice) { out.notice = notice; }
58
+ return out;
59
+ }
60
+
61
+ /** @returns {{kind:'selection_required', requested:string, suggestions:Array}} */
62
+ function selectionRequired({ requested, suggestions }) {
63
+ return { kind: 'selection_required', requested, suggestions: suggestions || [] };
64
+ }
65
+
66
+ /** @returns {{kind:'error', type:'model_route_error', ...}} */
67
+ function routeError({ field, requested, reason, preferredGateway, suggestions }) {
68
+ return { kind: 'error', type: 'model_route_error', field: field || 'model',
69
+ requested, reason, preferredGateway, suggestions: suggestions || [] };
70
+ }
71
+
72
+ module.exports = { GATEWAY_MODES, parseDescriptor, resolved, selectionRequired, routeError };
@@ -16,13 +16,7 @@ const ANTHROPIC_MODELS = [
16
16
  { id: 'anthropic/claude-3-5-haiku', name: 'Claude 3.5 Haiku', contextLength: null, pricing: null }
17
17
  ];
18
18
 
19
- const PROVIDER_FAMILY_NAMES = {
20
- openrouter: 'OpenRouter',
21
- google: 'Google',
22
- openai: 'OpenAI',
23
- anthropic: 'Anthropic',
24
- deepseek: 'DeepSeek'
25
- };
19
+ const { PROVIDER_FAMILY_NAMES } = require('./provider-registry');
26
20
 
27
21
  /** Provider API configs for fetching model lists */
28
22
  const PROVIDER_FETCH_CONFIG = {
@@ -82,7 +76,20 @@ const PROVIDER_FETCH_CONFIG = {
82
76
  pricing: null
83
77
  }));
84
78
  }
85
- }
79
+ },
80
+ anthropic: {
81
+ url: 'https://api.anthropic.com/v1/models',
82
+ authHeader: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
83
+ normalize: (body) => {
84
+ const data = JSON.parse(body);
85
+ return (data.data || []).map(m => ({
86
+ id: `anthropic/${m.id}`,
87
+ name: m.display_name || m.id,
88
+ contextLength: null,
89
+ pricing: null,
90
+ }));
91
+ },
92
+ },
86
93
  };
87
94
 
88
95
  const FETCH_TIMEOUT_MS = 5000;
@@ -95,7 +102,14 @@ const FETCH_TIMEOUT_MS = 5000;
95
102
  */
96
103
  function fetchModelsFromProvider(provider, key) {
97
104
  if (provider === 'anthropic') {
98
- return Promise.resolve(ANTHROPIC_MODELS);
105
+ // No key -> hardcoded floor, no network. With a key -> try live, fall back to floor.
106
+ // Floor-fallback rows are tagged authoritative:false (#61 4.3) so classifyModel
107
+ // never hard-blocks a miss against a stale/hardcoded list -- it returns
108
+ // 'unknown' instead. Rows from a successful live fetch are NOT tagged (they
109
+ // are authoritative). Map to new objects; never mutate ANTHROPIC_MODELS in place.
110
+ if (!key) { return Promise.resolve(ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))); }
111
+ return fetchViaConfig('anthropic', key).then(rows =>
112
+ (rows.length > 0 ? rows : ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))));
99
113
  }
100
114
 
101
115
  const config = PROVIDER_FETCH_CONFIG[provider];
@@ -103,6 +117,18 @@ function fetchModelsFromProvider(provider, key) {
103
117
  return Promise.resolve([]);
104
118
  }
105
119
 
120
+ return fetchViaConfig(provider, key);
121
+ }
122
+
123
+ /**
124
+ * Perform the HTTPS fetch + normalize for a single configured provider.
125
+ * Resolves to `[]` on any non-200 response, network error, timeout, or parse error.
126
+ * @param {string} provider - Key into PROVIDER_FETCH_CONFIG
127
+ * @param {string} key - API key
128
+ * @returns {Promise<Array>} Normalized model rows, or [] on any failure
129
+ */
130
+ function fetchViaConfig(provider, key) {
131
+ const config = PROVIDER_FETCH_CONFIG[provider];
106
132
  const url = config.buildUrl ? config.buildUrl(key) : config.url;
107
133
  const headers = config.authHeader(key);
108
134
 
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module model-input-default
5
+ * Shared "no --model given" default-lookup, extracted so it lives in exactly
6
+ * one place (#61 Task 6.2 review follow-up): both the CLI's resolveLaunchModel
7
+ * (start-helpers.js) and the MCP amicus_start handler (mcp-server.js) need to
8
+ * fall back to the configured default before handing a model off to
9
+ * resolveRouteForLaunch — without it, an omitted model reaches
10
+ * resolveRouteForLaunch as undefined -> parseDescriptor(undefined) -> an
11
+ * `invalid` descriptor, breaking the common "no --model" launch case.
12
+ * A leaf module (no other src/ module requires/mocks it), so introducing it
13
+ * doesn't reshape any existing jest.doMock('../src/utils/route-launch', ...)
14
+ * or jest.doMock('../src/utils/config', ...) call shape.
15
+ */
16
+
17
+ /**
18
+ * Resolve the raw model input for a launch: an explicit value (including one
19
+ * a caller may have already normalized) passes through unchanged; otherwise
20
+ * falls back to the configured default.
21
+ * @param {string|null|undefined} inputModel
22
+ * @returns {string|undefined} inputModel as-is, the configured default, or
23
+ * undefined if neither exists (the caller decides how to report that).
24
+ */
25
+ function resolveModelInputOrDefault(inputModel) {
26
+ if (inputModel !== undefined && inputModel !== null) { return inputModel; }
27
+ const { loadConfig } = require('./config');
28
+ const cfg = loadConfig();
29
+ return (cfg && cfg.default) ? cfg.default : undefined;
30
+ }
31
+
32
+ module.exports = { resolveModelInputOrDefault };
@@ -7,10 +7,7 @@
7
7
  */
8
8
 
9
9
  const readline = require('readline');
10
- const { fetchModelsFromProvider } = require('./model-fetcher');
11
- const { readApiKeyValues } = require('./api-key-store');
12
10
  const { loadConfig, saveConfig, getConfigPath } = require('./config');
13
- const { logger } = require('./logger');
14
11
 
15
12
  /**
16
13
  * Normalize a model ID to include the provider prefix.
@@ -33,54 +30,6 @@ const ALIAS_SEARCH_TERMS = {
33
30
  'deepseek': 'deepseek',
34
31
  };
35
32
 
36
- /**
37
- * Validate a direct-API fallback model exists on the provider.
38
- * Returns silently if valid. On failure: prompts (interactive) or throws (headless).
39
- *
40
- * @param {string} resolvedModel - e.g. 'google/gemini-3.1-flash-lite-preview'
41
- * @param {string} alias - Original alias name (e.g. 'gemini')
42
- * @param {object} [options]
43
- * @param {boolean} [options.headless] - If true, throw instead of prompting
44
- * @returns {Promise<string>} Confirmed model string
45
- */
46
- async function validateDirectModel(resolvedModel, alias, options = {}) {
47
- const parts = resolvedModel.split('/');
48
- if (parts.length < 2) { return resolvedModel; }
49
-
50
- const provider = parts[0];
51
- const modelId = parts.slice(1).join('/');
52
-
53
- const keys = readApiKeyValues();
54
- const providerKey = keys[provider];
55
- if (!providerKey) { return resolvedModel; }
56
-
57
- let models;
58
- try {
59
- models = await fetchModelsFromProvider(provider, providerKey);
60
- } catch (err) {
61
- logger.debug({ msg: 'Model fetch failed, skipping validation', error: err.message });
62
- return resolvedModel;
63
- }
64
-
65
- if (!models || models.length === 0) { return resolvedModel; }
66
-
67
- const found = models.some(m => m.id === resolvedModel || m.id === modelId);
68
- if (found) { return resolvedModel; }
69
-
70
- const relevant = filterRelevantModels(models, alias);
71
-
72
- if (options.headless || !process.stdin.isTTY) {
73
- const list = relevant.slice(0, 10).map(m => ` ${normalizeModelId(provider, m.id)}`).join('\n');
74
- throw new Error(
75
- `Model '${modelId}' not found on ${provider} API.\n` +
76
- `Available models:\n${list}\n` +
77
- `Fix with: amicus setup --add-alias ${alias}=${relevant[0] ? normalizeModelId(provider, relevant[0].id) : 'provider/model'}`
78
- );
79
- }
80
-
81
- return promptModelSelection(relevant, alias, provider, modelId);
82
- }
83
-
84
33
  /**
85
34
  * Filter models to those relevant to the alias
86
35
  * @param {Array<{id: string, name: string}>} models
@@ -101,54 +50,83 @@ function filterRelevantModels(models, alias) {
101
50
  return filtered.slice(0, 15);
102
51
  }
103
52
 
104
- /** Interactive prompt — ask user to pick from available models */
105
- async function promptModelSelection(models, alias, provider, failedModelId) {
106
- process.stderr.write(`\n Model '${failedModelId}' not found on ${provider} API.\n`);
107
- process.stderr.write(' Available models:\n');
108
- models.forEach((m, i) => {
109
- const label = (m.name && m.name !== m.id) ? `${m.name} (${m.id})` : m.id;
110
- process.stderr.write(` ${i + 1}. ${label}\n`);
53
+ /**
54
+ * Interactive alternatives picker for a direct-model miss (#61 Task 6.3, spec
55
+ * Decision 10). Presents `selectionResult.suggestions` (built upstream by
56
+ * route-launch.js's buildSuggestions) as a labeled numbered menu and lets the
57
+ * user pick one, or cancel. Uses the same readline + persist pattern (same
58
+ * save-with-malformed-config guard) as other model pickers in this module,
59
+ * adapted to the `{model, gateway, note}` suggestion shape instead of
60
+ * provider `{id, name}` rows.
61
+ *
62
+ * Never auto-selects — even a single suggestion still requires an explicit
63
+ * pick. Cancellation (empty input, an out-of-range number, or no suggestions
64
+ * to offer) always throws; the caller (resolveLaunchModel) is expected to
65
+ * catch and translate that into a "cancelled" stderr message + exit(1).
66
+ *
67
+ * @param {{requested: string, suggestions: Array<{model:string, gateway:string, note?:string}>}} selectionResult
68
+ * @param {string|undefined} alias - alias to persist the choice under, if any
69
+ * @returns {Promise<{model: string, gateway: string}>}
70
+ */
71
+ async function promptRouteSelection(selectionResult, alias) {
72
+ const suggestions = (selectionResult && Array.isArray(selectionResult.suggestions))
73
+ ? selectionResult.suggestions : [];
74
+ const requested = selectionResult && selectionResult.requested;
75
+
76
+ process.stderr.write(`\n Model '${requested}' isn't available on the direct API.\n`);
77
+
78
+ if (suggestions.length === 0) {
79
+ process.stderr.write(' No alternatives available.\n\n');
80
+ throw new Error('Model selection cancelled.');
81
+ }
82
+
83
+ process.stderr.write(' Alternatives:\n');
84
+ suggestions.forEach((s, i) => {
85
+ const note = s && s.note ? ` — ${s.note}` : '';
86
+ process.stderr.write(` ${i + 1}. ${s && s.model} [${s && s.gateway}]${note}\n`);
111
87
  });
112
88
  process.stderr.write('\n');
113
89
 
114
90
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
115
91
 
116
92
  const answer = await new Promise(resolve => {
117
- rl.question(` Select a model (1-${models.length}) or press Enter to cancel: `, resolve);
93
+ rl.question(` Select (1-${suggestions.length}) or Enter to cancel: `, resolve);
118
94
  });
119
95
  rl.close();
120
96
 
121
97
  const idx = parseInt(answer, 10) - 1;
122
- if (isNaN(idx) || idx < 0 || idx >= models.length) {
98
+ if (isNaN(idx) || idx < 0 || idx >= suggestions.length) {
123
99
  throw new Error('Model selection cancelled.');
124
100
  }
125
101
 
126
- const selected = models[idx];
127
- const newModel = normalizeModelId(provider, selected.id);
128
-
129
- let config = loadConfig();
130
- if (!config) {
131
- const fs = require('fs');
132
- const configPath = getConfigPath();
133
- if (fs.existsSync(configPath)) {
134
- throw new Error(
135
- `Cannot save model selection: config file at ${configPath} is malformed. ` +
136
- 'Fix it manually or run \'amicus setup\'.'
137
- );
102
+ const chosen = suggestions[idx];
103
+ const newModel = chosen.model;
104
+
105
+ if (alias) {
106
+ let config = loadConfig();
107
+ if (!config) {
108
+ const fs = require('fs');
109
+ const configPath = getConfigPath();
110
+ if (fs.existsSync(configPath)) {
111
+ throw new Error(
112
+ `Cannot save model selection: config file at ${configPath} is malformed. ` +
113
+ 'Fix it manually or run \'amicus setup\'.'
114
+ );
115
+ }
116
+ config = {};
117
+ }
118
+ if (!config.aliases) { config.aliases = {}; }
119
+ config.aliases[alias] = newModel;
120
+ try {
121
+ saveConfig(config);
122
+ process.stderr.write(` Saved: ${alias} -> ${newModel}\n`);
123
+ } catch (err) {
124
+ process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
138
125
  }
139
- config = {};
140
- }
141
- if (!config.aliases) { config.aliases = {}; }
142
- config.aliases[alias] = newModel;
143
- try {
144
- saveConfig(config);
145
- process.stderr.write(` Saved: ${alias} → ${newModel}\n`);
146
- } catch (err) {
147
- process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
148
126
  }
149
- process.stderr.write(` (To change later: amicus setup --add-alias ${alias}=...)\n\n`);
127
+ process.stderr.write('\n');
150
128
 
151
- return newModel;
129
+ return { model: newModel, gateway: chosen.gateway };
152
130
  }
153
131
 
154
132
  /**
@@ -204,4 +182,10 @@ async function warnIfNotInCatalog(model) {
204
182
  }
205
183
  }
206
184
 
207
- module.exports = { validateDirectModel, filterRelevantModels, normalizeModelId, validateAgainstCatalog, warnIfNotInCatalog };
185
+ module.exports = {
186
+ filterRelevantModels,
187
+ normalizeModelId,
188
+ validateAgainstCatalog,
189
+ warnIfNotInCatalog,
190
+ promptRouteSelection,
191
+ };