amicus 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +66 -0
  3. package/README.md +13 -4
  4. package/electron/setup-ui-aliases.js +1 -1
  5. package/electron/setup-ui.js +27 -3
  6. package/package.json +2 -1
  7. package/skills/second-opinion/SKILL.md +2 -0
  8. package/skills/sidecar/SKILL.md +66 -38
  9. package/src/cli-handlers-resume-continue.js +31 -4
  10. package/src/cli-handlers-run.js +15 -4
  11. package/src/cli.js +17 -0
  12. package/src/mcp-server.js +99 -12
  13. package/src/mcp-tools.js +26 -4
  14. package/src/opencode-client.js +18 -2
  15. package/src/sidecar/continue.js +10 -3
  16. package/src/sidecar/fanout-leg.js +26 -1
  17. package/src/sidecar/fanout-output.js +5 -0
  18. package/src/sidecar/fanout-validate.js +81 -0
  19. package/src/sidecar/fanout.js +65 -77
  20. package/src/sidecar/models.js +39 -17
  21. package/src/sidecar/session-utils.js +6 -0
  22. package/src/sidecar/setup.js +2 -1
  23. package/src/utils/alias-resolver.js +6 -35
  24. package/src/utils/api-key-store.js +1 -9
  25. package/src/utils/auth-json.js +1 -1
  26. package/src/utils/config.js +98 -16
  27. package/src/utils/curated-models.js +99 -8
  28. package/src/utils/gateway-route-audit.js +103 -0
  29. package/src/utils/gateway-route-catalog.js +92 -0
  30. package/src/utils/gateway-router.js +131 -0
  31. package/src/utils/input-validators.js +12 -42
  32. package/src/utils/model-classification.js +65 -0
  33. package/src/utils/model-descriptor.js +72 -0
  34. package/src/utils/model-fetcher.js +46 -14
  35. package/src/utils/model-input-default.js +32 -0
  36. package/src/utils/model-validator.js +68 -84
  37. package/src/utils/provider-registry.js +57 -0
  38. package/src/utils/quick-picks.js +11 -3
  39. package/src/utils/result-schema-rebuild.js +98 -0
  40. package/src/utils/result-schema.js +15 -78
  41. package/src/utils/route-error.js +154 -0
  42. package/src/utils/route-launch.js +219 -0
  43. package/src/utils/start-helpers.js +96 -43
  44. package/src/utils/validators.js +1 -8
@@ -121,9 +121,10 @@ function waveExitCode(waveStatus) {
121
121
  * @param {string|null} [opts.createdAt]
122
122
  * @param {string|null} [opts.completedAt]
123
123
  * @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
124
+ * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
124
125
  * @returns {object} wave document
125
126
  */
126
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
127
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
127
128
  const { sumWaveUsage } = require('./pricing');
128
129
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
129
130
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -149,84 +150,13 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
149
150
  completedAt,
150
151
  durationMs,
151
152
  usage: sumWaveUsage(legs),
153
+ notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
152
154
  };
153
155
  }
154
156
 
155
- /**
156
- * Rebuild a run document from a persisted session directory.
157
- * @param {string} project - Project dir
158
- * @param {string} taskId
159
- * @returns {object} run document
160
- * @throws {Error} if the session does not exist or metadata.json is missing/corrupt
161
- */
162
- function buildRunResultFromSession(project, taskId) {
163
- const fs = require('fs');
164
- const path = require('path');
165
- const { resolveExistingSessionDir } = require('../session-manager');
166
- const sessionDir = resolveExistingSessionDir(project, taskId);
167
- const metaPath = path.join(sessionDir, 'metadata.json');
168
- if (!fs.existsSync(metaPath)) {
169
- throw new Error(`Session ${taskId} not found`);
170
- }
171
- let metadata;
172
- try {
173
- metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
174
- } catch (err) {
175
- throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
176
- }
177
- const summaryPath = path.join(sessionDir, 'summary.md');
178
- const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
179
- return buildRunResult({ taskId, metadata, summary, sessionDir });
180
- }
181
-
182
- /**
183
- * Rebuild a wave document. Prefers the stored wave.json (written atomically at
184
- * fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
185
- * hard kill of the fanout process).
186
- * @param {string} project
187
- * @param {string} waveId
188
- * @returns {object} wave document
189
- * @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
190
- */
191
- function buildWaveResultFromSession(project, waveId) {
192
- const fs = require('fs');
193
- const path = require('path');
194
- const { resolveExistingSessionDir } = require('../session-manager');
195
- const waveDir = resolveExistingSessionDir(project, waveId);
196
- const wavePath = path.join(waveDir, 'wave.json');
197
- if (fs.existsSync(wavePath)) {
198
- try {
199
- return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
200
- } catch {
201
- // Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
202
- }
203
- }
204
- const metaPath = path.join(waveDir, 'metadata.json');
205
- if (!fs.existsSync(metaPath)) {
206
- throw new Error(`Wave ${waveId} not found`);
207
- }
208
- let meta;
209
- try {
210
- meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
211
- } catch (err) {
212
- throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
213
- }
214
- const legs = (meta.legs || []).map((legId) => {
215
- try { return buildRunResultFromSession(project, legId); }
216
- catch (err) {
217
- const { logger } = require('./logger');
218
- logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
219
- return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
220
- }
221
- });
222
- return buildWaveResult({
223
- waveId,
224
- legs,
225
- promptMeta: meta.promptMeta || null,
226
- createdAt: meta.createdAt || null,
227
- completedAt: meta.completedAt || null,
228
- });
229
- }
157
+ // buildRunResultFromSession/buildWaveResultFromSession live in
158
+ // ./result-schema-rebuild.js (size-gate split); re-exported below.
159
+ const { buildRunResultFromSession, buildWaveResultFromSession } = require('./result-schema-rebuild');
230
160
 
231
161
  /**
232
162
  * Build a model-catalog document (`models [--search] [--refresh] --json`).
@@ -252,15 +182,22 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
252
182
 
253
183
  /**
254
184
  * Build an alias-audit document (`models --check --json`).
255
- * @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean}} opts
185
+ * `gatewayFindings` (Task 6, #gwid) is additive: the per-gateway-form audit
186
+ * of curated DEFAULT aliases (toGatewayRoutes() vs. the live catalog),
187
+ * distinct from the flat `stale` audit above. Defaults to [] so existing
188
+ * callers that omit it are unaffected.
189
+ * @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean,
190
+ * gatewayFindings?: Array<{alias,gateway,kind,model,expected?}>}} opts
256
191
  */
257
- function buildAuditDoc({ stale, catalogAvailable }) {
192
+ function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
258
193
  return {
259
194
  schemaVersion: SCHEMA_VERSION,
260
195
  type: 'alias-audit',
261
196
  catalogAvailable,
262
197
  staleCount: stale.length,
263
198
  stale,
199
+ gatewayFindingsCount: gatewayFindings.length,
200
+ gatewayFindings,
264
201
  };
265
202
  }
266
203
 
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @module route-error
3
+ * Shared renderer (#61 Task 6.1): turns a router RouteResult — an error or a
4
+ * selection_required — into the two surfaces that need to explain it:
5
+ * - `toStructuredError` -> the MCP-facing structured object
6
+ * - `toCliMessage` -> a human stderr string for the CLI
7
+ *
8
+ * Pure module: no I/O, no requires of launch modules (cli.js/headless.js/
9
+ * mcp-server.js/etc). Wired into live launch paths — start-helpers.js,
10
+ * sidecar/fanout-leg.js, and mcp-server.js all render RouteResults through
11
+ * toStructuredError/toCliMessage.
12
+ *
13
+ * Router error shape (src/utils/model-descriptor.js `routeError()`):
14
+ * {kind:'error', type:'model_route_error', field, requested, reason,
15
+ * preferredGateway, suggestions}
16
+ * Selection shape (`selectionRequired()`):
17
+ * {kind:'selection_required', requested, suggestions}
18
+ *
19
+ * The router's error `reason` is NOT limited to the 7 values in
20
+ * ROUTE_ERROR_REASONS below — that array is just the original/base set,
21
+ * intentionally pinned as-is (see its own doc comment). The router can also
22
+ * emit availability reasons (`direct_unavailable`, `openrouter_unavailable`),
23
+ * which have REASON_TEXT/FIX_HINTS entries but are deliberately excluded from
24
+ * ROUTE_ERROR_REASONS. A `selection_required` result has no `reason` of its
25
+ * own — it is synthesized here as SELECTION_REQUIRED_REASON, kept in the same
26
+ * documented REASON_TEXT map rather than invented ad hoc, so callers can
27
+ * treat every rendered structured error the same way regardless of which
28
+ * RouteResult produced it.
29
+ */
30
+ 'use strict';
31
+
32
+ /**
33
+ * The original/base set of router error reasons — NOT an exhaustive list of
34
+ * every reason a router error can carry. Pinned to exactly these 7 values by
35
+ * a back-compat test (route-error.test.js:14-24), so this array must not be
36
+ * extended when new reasons are added. The router also emits
37
+ * `direct_unavailable` and `openrouter_unavailable` (REASON_TEXT/FIX_HINTS
38
+ * below have entries for both); those are intentionally left out of this
39
+ * array. Do not use ROUTE_ERROR_REASONS as an exhaustive switch/allow-list.
40
+ */
41
+ const ROUTE_ERROR_REASONS = Object.freeze([
42
+ 'gateway_conflict',
43
+ 'no_openrouter_key',
44
+ 'no_direct_integration',
45
+ 'no_direct_key',
46
+ 'no_key_for_vendor',
47
+ 'model_not_found',
48
+ 'invalid_descriptor',
49
+ ]);
50
+
51
+ /**
52
+ * Synthesized reason for a `selection_required` RouteResult. Deliberately
53
+ * distinct from 'model_not_found': the model wasn't missing, it was ambiguous
54
+ * (multiple catalog candidates) and the router is asking the caller to pick.
55
+ */
56
+ const SELECTION_REQUIRED_REASON = 'selection_required';
57
+
58
+ /** One-line, non-technical explanation of what went wrong, keyed by reason. */
59
+ const REASON_TEXT = Object.freeze({
60
+ gateway_conflict: 'This model must go through OpenRouter, but --gateway direct was forced.',
61
+ no_openrouter_key: 'No OpenRouter API key is configured.',
62
+ no_direct_integration: 'This vendor has no direct API integration.',
63
+ no_direct_key: "No API key is configured for this vendor's direct API.",
64
+ no_key_for_vendor: 'No API key was found for this vendor via any gateway.',
65
+ model_not_found: 'The requested model was not found in the catalog.',
66
+ invalid_descriptor: 'The model identifier could not be parsed.',
67
+ direct_unavailable: "This model isn't available on the vendor's direct API; use OpenRouter or a different model.",
68
+ openrouter_unavailable: "This model isn't on OpenRouter; use --gateway direct or a different model.",
69
+ [SELECTION_REQUIRED_REASON]: 'Multiple models match your request; a specific one must be selected.',
70
+ });
71
+
72
+ /** Copy-paste fix guidance appended after the REASON_TEXT sentence. */
73
+ const FIX_HINTS = Object.freeze({
74
+ gateway_conflict: "An openrouter/... model can't be forced with --gateway direct.",
75
+ no_openrouter_key: 'Set OPENROUTER_API_KEY, or use --gateway direct.',
76
+ no_direct_integration: 'This vendor has no direct integration; drop --gateway direct.',
77
+ no_direct_key: 'Add a key with `amicus key <vendor> <key>`, or use --gateway openrouter.',
78
+ no_key_for_vendor: 'Add a provider key or an OpenRouter key.',
79
+ model_not_found: 'Run `amicus models --refresh`, or pass --no-validate-model.',
80
+ invalid_descriptor: 'Use a vendor/model id or a configured alias.',
81
+ direct_unavailable: 'Drop --gateway direct (use auto or --gateway openrouter), or pick a different model.',
82
+ openrouter_unavailable: 'Use --gateway direct, or pick a different model.',
83
+ [SELECTION_REQUIRED_REASON]: 'Pick one of the suggestions below, or narrow the model id.',
84
+ });
85
+
86
+ /** @returns {Array} suggestions normalized to an array. */
87
+ function normalizeSuggestions(suggestions) {
88
+ return Array.isArray(suggestions) ? suggestions : [];
89
+ }
90
+
91
+ /**
92
+ * Render a router RouteResult (error or selection_required) into the
93
+ * MCP-facing structured object. Pass-through/normalize for an `error` result;
94
+ * synthesized for a `selection_required` result.
95
+ * @param {object} result a RouteResult with kind 'error' or 'selection_required'
96
+ * @returns {{type:'model_route_error', field:string, requested:*, reason:string,
97
+ * preferredGateway:(string|null), suggestions:Array}}
98
+ */
99
+ function toStructuredError(result) {
100
+ const r = result || {};
101
+ if (r.kind === 'selection_required') {
102
+ return {
103
+ type: 'model_route_error',
104
+ field: 'model',
105
+ requested: r.requested,
106
+ reason: SELECTION_REQUIRED_REASON,
107
+ preferredGateway: null,
108
+ suggestions: normalizeSuggestions(r.suggestions),
109
+ };
110
+ }
111
+ // Router `error` result (kind:'error'): pass through/normalize.
112
+ return {
113
+ type: 'model_route_error',
114
+ field: r.field || 'model',
115
+ requested: r.requested,
116
+ reason: r.reason,
117
+ preferredGateway: r.preferredGateway || null,
118
+ suggestions: normalizeSuggestions(r.suggestions),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Render a router RouteResult into a human stderr message: the reason's
124
+ * one-line explanation, an optional "Did you mean" suggestion list, and a
125
+ * fix hint.
126
+ * @param {object} result a RouteResult with kind 'error' or 'selection_required'
127
+ * @returns {string}
128
+ */
129
+ function toCliMessage(result) {
130
+ const err = toStructuredError(result);
131
+ const sentence = REASON_TEXT[err.reason] || `Model routing error (${err.reason}).`;
132
+ const lines = [err.requested ? `${sentence} (requested "${err.requested}")` : sentence];
133
+
134
+ if (err.suggestions.length > 0) {
135
+ lines.push('Did you mean:');
136
+ for (const s of err.suggestions) {
137
+ const note = s && s.note ? ` — ${s.note}` : '';
138
+ lines.push(` - ${s && s.model} (${s && s.gateway})${note}`);
139
+ }
140
+ }
141
+
142
+ const hint = FIX_HINTS[err.reason];
143
+ if (hint) { lines.push(hint); }
144
+
145
+ return lines.join('\n');
146
+ }
147
+
148
+ module.exports = {
149
+ toStructuredError,
150
+ toCliMessage,
151
+ REASON_TEXT,
152
+ ROUTE_ERROR_REASONS,
153
+ SELECTION_REQUIRED_REASON,
154
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Route-launch views (#61 gateway routing integration, Task 4.2).
3
+ *
4
+ * Read-only helpers consumed by resolveRouteForLaunch, which IS wired into
5
+ * live launch paths (start-helpers.js, mcp-server.js, sidecar/fanout-validate.js).
6
+ * Pure-ish: all I/O goes through the stubbable api-key-store / auth-json /
7
+ * model-catalog modules.
8
+ */
9
+ 'use strict';
10
+
11
+ const { readApiKeys } = require('./api-key-store');
12
+ const { readAuthJsonKeys } = require('./auth-json');
13
+ const { KNOWN_PROVIDERS } = require('./provider-registry');
14
+
15
+ /**
16
+ * Per-provider key presence across BOTH sources: env/.env (readApiKeys) and
17
+ * OpenCode's auth.json (readAuthJsonKeys). True if either source has a key
18
+ * for that provider (Foundation carry-forward, Decision 5).
19
+ * @returns {Object<string,boolean>} map of provider id -> key present
20
+ */
21
+ function buildLaunchKeys() {
22
+ const env = readApiKeys(); // {openrouter:bool, google:bool, openai:bool, anthropic:bool, deepseek:bool}
23
+ const authKeys = readAuthJsonKeys(); // {provider:string,...} (only providers with keys)
24
+ const out = {};
25
+ for (const p of KNOWN_PROVIDERS) {
26
+ out[p] = !!env[p] || !!authKeys[p];
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Thin wrapper over model-catalog.getCatalogInfo() for route-resolution
33
+ * callers that only need the models list and the last-refresh error, not the
34
+ * full cache metadata. Never throws: a catalog error resolves to an empty
35
+ * list with a sentinel error string.
36
+ * @returns {Promise<{models: Array, lastRefreshError: string|null}>}
37
+ */
38
+ async function getRouteCatalogInfo() {
39
+ // Lazy-required so jest.doMock('./model-catalog', ...) can intercept it
40
+ // per-test, matching the pattern model-catalog.js itself uses for its deps.
41
+ const { getCatalogInfo } = require('./model-catalog');
42
+ try {
43
+ const info = await getCatalogInfo();
44
+ return { models: info.models || [], lastRefreshError: info.lastRefreshError || null };
45
+ } catch {
46
+ return { models: [], lastRefreshError: 'catalog-unavailable' };
47
+ }
48
+ }
49
+
50
+ /** Module version stamped onto `resolved` results' provenance (carry-forward). */
51
+ const ROUTE_VERSION = 1;
52
+
53
+ /**
54
+ * Build up to ~6 labeled alternatives for a `selection_required` RouteResult
55
+ * (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
56
+ * descriptor plus the live keys/catalogInfo/gatewayIds the caller already
57
+ * assembled.
58
+ *
59
+ * Two categories, in order:
60
+ * 1. The SAME model via OpenRouter — only when an OpenRouter key is present
61
+ * AND the OR-namespaced id is actually present in the catalog (never
62
+ * suggest an id we can't confirm exists). For divergent vendors (e.g.
63
+ * Anthropic) `descriptor.model` may be the DASH-form direct id, so a
64
+ * reconstructed `openrouter/<vendor>/<model>` would never match the
65
+ * catalog's dot-form OR id -- when the caller's `gatewayIds.openrouter`
66
+ * is available (the catalog-correct form), it is used instead of
67
+ * reconstructing. Falls back to reconstruction when `gatewayIds` is
68
+ * absent (non-alias / full-id / non-divergent requests), so behavior
69
+ * there is unchanged.
70
+ * 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
71
+ * `<vendor>/`, excluding the requested id itself and excluding any
72
+ * `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
73
+ * only when vendor === 'openrouter' and are filtered out defensively).
74
+ *
75
+ * @param {{vendor?: string, model?: string}} descriptor parsed Descriptor for
76
+ * the request that produced the selection_required (canonical or
77
+ * openrouter-literal — both carry vendor/model)
78
+ * @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
79
+ * @param {{models: Array<{id:string}>}} catalogInfo
80
+ * @param {{direct?: string, openrouter?: string}} [gatewayIds] the same
81
+ * per-gateway id map resolveRouteForLaunch threads through resolveRoute
82
+ * (Task 3's bridge for divergent curated aliases); absent for non-alias /
83
+ * full-id / non-divergent requests
84
+ * @returns {Array<{model:string, gateway:string, note:string}>}
85
+ */
86
+ function buildSuggestions(descriptor, keys, catalogInfo, gatewayIds) {
87
+ const suggestions = [];
88
+ const vendor = descriptor && descriptor.vendor;
89
+ const model = descriptor && descriptor.model;
90
+ if (!vendor || !model) { return suggestions; }
91
+
92
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
93
+ const requestedDirectId = `${vendor}/${model}`;
94
+
95
+ if (keys && keys.openrouter) {
96
+ const orId = (gatewayIds && gatewayIds.openrouter) || `openrouter/${vendor}/${model}`;
97
+ if (models.some(m => m && m.id === orId)) {
98
+ suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
99
+ }
100
+ }
101
+
102
+ const nsPrefix = `${vendor}/`;
103
+ const sameVendor = models.filter(m =>
104
+ m && typeof m.id === 'string' &&
105
+ m.id.startsWith(nsPrefix) &&
106
+ !m.id.startsWith('openrouter/') &&
107
+ m.id !== requestedDirectId
108
+ ).slice(0, 5);
109
+ for (const m of sameVendor) {
110
+ suggestions.push({ model: m.id, gateway: 'direct', note: `${vendor} model` });
111
+ }
112
+
113
+ return suggestions.slice(0, 6);
114
+ }
115
+
116
+ /**
117
+ * One-time per-vendor notice when auto-routing migrates a both-key holder off
118
+ * OpenRouter onto direct (#61 Task 5.1 — visible-migration guarantee: never
119
+ * silent). Advisory only — never changes the routing decision, and a failed
120
+ * persist (markMigrationNotified is itself best-effort) never blocks the
121
+ * launch. Fires only when ALL of these hold:
122
+ * - the result actually resolved to gateway 'direct'
123
+ * - the caller did NOT explicitly force a gateway (gatewayMode === 'auto');
124
+ * an explicit --gateway direct means the user chose direct, no notice
125
+ * - the descriptor is not itself an explicit `openrouter/...` literal
126
+ * - the user holds an OpenRouter key (otherwise nothing is being migrated
127
+ * FROM)
128
+ * - this vendor hasn't already been notified (getRoutingConfig().migration_notified)
129
+ * @param {{result:object, descriptor:object, gatewayMode:string, keys:object}} args
130
+ * @returns {object} the (possibly mutated) result
131
+ */
132
+ function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
133
+ if (result.kind !== 'resolved' || result.gateway !== 'direct') { return result; }
134
+ if (gatewayMode !== 'auto') { return result; }
135
+ if (descriptor.isExplicitOpenRouter) { return result; }
136
+ if (!keys.openrouter) { return result; }
137
+ try {
138
+ // Lazy-required so jest.doMock('./config', ...) can intercept it per-test.
139
+ const { getRoutingConfig, markMigrationNotified } = require('./config');
140
+ if (getRoutingConfig().migration_notified[descriptor.vendor]) { return result; }
141
+ const notice = `Routing ${descriptor.vendor} via direct API (previously OpenRouter). ` +
142
+ 'Set routing.prefer: "openrouter" (or use --gateway openrouter) to restore.';
143
+ result.notice = result.notice ? `${result.notice} ${notice}` : notice;
144
+ markMigrationNotified(descriptor.vendor);
145
+ } catch (_err) {
146
+ // Advisory only: never let a lookup/persist failure change the routing
147
+ // decision or block the launch.
148
+ }
149
+ return result;
150
+ }
151
+
152
+ /**
153
+ * Bridge: alias -> descriptor -> resolveRoute (Task 4.4; gatewayIds bridging
154
+ * Task 3 of #61's gateway-correct-model-ids fix).
155
+ * Resolves a raw model string to a Descriptor — if it is a known no-slash
156
+ * alias (per getEffectiveAliases()), its concrete id is parsed instead, so an
157
+ * alias pointing at an `openrouter/...` value is treated as an explicit,
158
+ * force-OR literal while an alias pointing at a bare `vendor/model` is
159
+ * policy-routed like any other canonical id.
160
+ *
161
+ * When the alias is UNMODIFIED from its curated default (effective value ===
162
+ * getDefaultAliases()[alias]), this also looks up curated-models'
163
+ * toGatewayRoutes()[alias] and threads it through as `gatewayIds`, and parses
164
+ * the descriptor from the gateway-native direct form (falling back to the
165
+ * openrouter form) rather than the single pinned alias string — the pinned
166
+ * string can be the wrong per-gateway form for divergent vendors (e.g.
167
+ * Anthropic's dash ids vs. OpenRouter's dot ids), so `toGatewayRoutes()` is
168
+ * the source of truth for actually-correct ids. A USER OVERRIDE (the
169
+ * effective alias differs from the curated default) or an alias with no
170
+ * curated route map intentionally skips all of this: no gatewayIds, and the
171
+ * user's own alias string is parsed as before — we must never impose curated
172
+ * Anthropic-style ids onto a target the user chose themselves. Full-id /
173
+ * non-alias inputs are likewise unaffected (no gatewayIds).
174
+ *
175
+ * Assembles live key/catalog state and delegates the actual decision to the
176
+ * pure gateway-router. Wired into start-helpers.js, mcp-server.js, and
177
+ * sidecar/fanout-validate.js.
178
+ * @param {{model:string, gatewayMode:string, source:string, allowSelection?:boolean, validateModel?:boolean}} opts
179
+ * @returns {Promise<object>} RouteResult (resolved | selection_required | error)
180
+ */
181
+ async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelection, validateModel }) {
182
+ // Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router' | './curated-models', ...)
183
+ // can intercept them per-test, matching the pattern already used above for model-catalog.
184
+ const { getEffectiveAliases, getDefaultAliases } = require('./config');
185
+ const { parseDescriptor } = require('./model-descriptor');
186
+ const { resolveRoute } = require('./gateway-router');
187
+ const { toGatewayRoutes } = require('./curated-models');
188
+ const aliases = getEffectiveAliases();
189
+ const isAlias = typeof model === 'string' && !model.includes('/') && !!aliases[model];
190
+ let concrete = isAlias ? aliases[model] : model;
191
+ let gatewayIds;
192
+ if (isAlias && aliases[model] === getDefaultAliases()[model]) {
193
+ const routes = toGatewayRoutes()[model];
194
+ if (routes) {
195
+ gatewayIds = routes;
196
+ concrete = routes.direct || routes.openrouter;
197
+ }
198
+ }
199
+ const descriptor = parseDescriptor(concrete, { aliases });
200
+ const keys = buildLaunchKeys();
201
+ // Skip the catalog fetch entirely under --no-validate-model: gateway-router's
202
+ // catalogGate short-circuits to { ok:true } as soon as validateModel === false,
203
+ // never consulting catalogInfo, so fetching it here would be wasted
204
+ // latency/network (and can hit the network on a cold cache) for no benefit.
205
+ // Strict === false (not just falsy) so this stays in lockstep with catalogGate's
206
+ // own `=== false` guard: any other value (incl. an omitted flag) still fetches,
207
+ // so a caller can never skip the fetch while the gate still classifies against it.
208
+ const catalogInfo = validateModel === false ? { models: [], lastRefreshError: null } : await getRouteCatalogInfo();
209
+ let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo, gatewayIds });
210
+ if (result.kind === 'resolved') {
211
+ result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };
212
+ result = maybeMigrationNotice({ result, descriptor, gatewayMode, keys });
213
+ } else if (result.kind === 'selection_required') {
214
+ result.suggestions = buildSuggestions(descriptor, keys, catalogInfo, gatewayIds);
215
+ }
216
+ return result;
217
+ }
218
+
219
+ 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
  };