amicus 3.1.0 → 3.2.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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Per-vendor cost tiers (economy/balanced/frontier) + resolution against the
3
+ * live model catalog.
4
+ *
5
+ * Tier ordering: frontier = MOST expensive/capable, economy = CHEAPEST,
6
+ * balanced = the middle ground.
7
+ *
8
+ * Mirrors quick-picks.pickCurrent's "newest live-catalog id matching a
9
+ * pattern in a vendor namespace" approach rather than reinventing it: each
10
+ * tier's regex is matched over the model segment in BOTH the direct
11
+ * namespace (`<vendor>/<model>`) and the OpenRouter namespace
12
+ * (`openrouter/<vendor>/<model>`); the direct-namespace pick wins when both
13
+ * exist, since storage is direct-first (see gateway-router.js).
14
+ *
15
+ * Gateway-only vendors (no direct API integration — provider-registry
16
+ * `isDirectProvider` false) have no tier regexes here: OpenRouter is their
17
+ * only route, and curated-models' CARDLESS/family-fallback entries don't
18
+ * carry a per-alias match pattern the way FAMILIES does, so there is no
19
+ * live-catalog rule to reuse for them. All three tiers resolve to the same
20
+ * curated flagship — the static canonical pin `toDefaultAliases()` already
21
+ * maintains for that vendor's alias (the same pin `resolveQuickPicks` falls
22
+ * back to when live resolution is unavailable).
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const { pickCurrent } = require('./quick-picks');
28
+ const { isDirectProvider } = require('./provider-registry');
29
+ const { toDefaultAliases, listCuratedRoutes } = require('./curated-models');
30
+
31
+ /** Tier regex table: pattern matches the model segment after `<vendor>/` (or `openrouter/<vendor>/`). */
32
+ const TIERS = {
33
+ anthropic: {
34
+ economy: /^claude-haiku-/,
35
+ balanced: /^claude-sonnet-/,
36
+ frontier: /^claude-opus-/,
37
+ },
38
+ openai: {
39
+ economy: /^gpt-[\d.]+-mini$/,
40
+ balanced: /^gpt-[\d.]+$/,
41
+ frontier: /^gpt-[\d.]+-pro$/,
42
+ },
43
+ google: {
44
+ economy: /^gemini-[\d.]+-flash-lite/,
45
+ balanced: /^gemini-[\d.]+-flash(?!-lite)/,
46
+ frontier: /^gemini-[\d.]+-pro/,
47
+ },
48
+ deepseek: {
49
+ economy: /^deepseek-v[\d.]+$/,
50
+ balanced: /^deepseek-v[\d.]+$/,
51
+ frontier: /^deepseek-v[\d.]+-pro$/,
52
+ },
53
+ };
54
+
55
+ /** Fallback preference order when the requested tier's pattern matches nothing. */
56
+ const TIER_ORDER = ['economy', 'balanced', 'frontier'];
57
+
58
+ /**
59
+ * vendor -> curated alias, for vendors with no direct integration and no
60
+ * TIERS entry. Built once from curated-models' OpenRouter-routed entries;
61
+ * the first alias found per vendor wins (e.g. 'qwen' over its
62
+ * 'qwen-coder'/'qwen-flash' siblings, since CARDLESS lists it first).
63
+ * @returns {Object<string,string>}
64
+ */
65
+ function buildGatewayOnlyAliasMap() {
66
+ const map = {};
67
+ for (const { alias, provider, model } of listCuratedRoutes()) {
68
+ if (provider !== 'openrouter' || !model.startsWith('openrouter/')) { continue; }
69
+ const rest = model.slice('openrouter/'.length); // '<vendor>/<rest...>'
70
+ const slash = rest.indexOf('/');
71
+ const vendor = slash > 0 ? rest.slice(0, slash) : null;
72
+ if (!vendor || isDirectProvider(vendor) || TIERS[vendor] || map[vendor]) { continue; }
73
+ map[vendor] = alias;
74
+ }
75
+ return map;
76
+ }
77
+
78
+ const GATEWAY_ONLY_ALIAS = buildGatewayOnlyAliasMap();
79
+
80
+ /** Newest catalog id matching `regex` under `vendor`; direct namespace preferred over OpenRouter's. */
81
+ function pickForTier(catalog, vendor, regex) {
82
+ return pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
83
+ }
84
+
85
+ /** True when the catalog has ANY row (any tier) under this vendor's namespace, in either gateway. */
86
+ function vendorHasModels(catalog, vendor) {
87
+ return Boolean(pickForTier(catalog, vendor, /./));
88
+ }
89
+
90
+ /**
91
+ * @param {string} vendor
92
+ * @param {'economy'|'balanced'|'frontier'} tier
93
+ * @param {Array<{id:string}>} catalog
94
+ * @returns {string|null} current live-catalog full id for vendor+tier
95
+ * (e.g. `anthropic/claude-sonnet-5`), or null when the vendor is unknown
96
+ * or absent from the catalog.
97
+ */
98
+ function resolveTier(vendor, tier, catalog) {
99
+ if (typeof vendor !== 'string' || !vendor) { return null; }
100
+ if (!TIER_ORDER.includes(tier)) { return null; }
101
+
102
+ const gatewayAlias = GATEWAY_ONLY_ALIAS[vendor];
103
+ if (gatewayAlias) { return toDefaultAliases()[gatewayAlias] || null; }
104
+
105
+ const table = TIERS[vendor];
106
+ if (!table) { return null; }
107
+
108
+ const direct = pickForTier(catalog, vendor, table[tier]);
109
+ if (direct) { return direct; }
110
+
111
+ if (!vendorHasModels(catalog, vendor)) { return null; }
112
+
113
+ for (const t of TIER_ORDER) {
114
+ const pick = pickForTier(catalog, vendor, table[t]);
115
+ if (pick) { return pick; }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ module.exports = { TIERS, resolveTier };
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Provider-default picker core (Part 2, Task 4).
3
+ *
4
+ * Builds the priced, tier-preselected choice list a vendor's model picker
5
+ * (CLI/Electron/readline, later tasks) renders. This module is PURE and
6
+ * transport-agnostic: the catalog is always injected by the caller, and it
7
+ * never imports a renderer's formatting helper (e.g. Electron's `fmtPrice`)
8
+ * -- `pricePerMInput` is returned as a raw number; each surface formats it.
9
+ *
10
+ * The one documented impurity: when `tier` is omitted, `getCostTier()` reads
11
+ * the user's persisted config (Task 1). Callers that need full purity can
12
+ * always pass `tier` explicitly to skip that read entirely.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const { resolveTier } = require('./model-tiers');
18
+ const { getCostTier, loadConfig, saveConfig } = require('./config');
19
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
20
+ const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
21
+
22
+ /**
23
+ * @param {{pricing?: {prompt?: string|number|null}|null}|null|undefined} orRow
24
+ * the OpenRouter-namespace catalog row paired to a logical model, if any.
25
+ * @returns {number|null} `pricing.prompt * 1e6` ($/M input tokens), or null
26
+ * when there is no priced OpenRouter twin.
27
+ */
28
+ function pricePerMInputFrom(orRow) {
29
+ if (!orRow || !orRow.pricing || orRow.pricing.prompt === null || orRow.pricing.prompt === undefined) {
30
+ return null;
31
+ }
32
+ const n = Number(orRow.pricing.prompt);
33
+ return Number.isFinite(n) ? n * 1e6 : null;
34
+ }
35
+
36
+ /**
37
+ * Every catalog row belonging to `vendor`, in either namespace (direct
38
+ * `<vendor>/<model>` or OpenRouter `openrouter/<vendor>/<model>`).
39
+ * @param {Array<{id:string}>} catalog
40
+ * @param {string} vendor
41
+ * @returns {Array<{id:string}>}
42
+ */
43
+ function vendorRowsIn(catalog, vendor) {
44
+ const directPrefix = `${vendor}/`;
45
+ const orPrefix = `openrouter/${vendor}/`;
46
+ return catalog.filter(r => r && typeof r.id === 'string' &&
47
+ (r.id.startsWith(directPrefix) || r.id.startsWith(orPrefix)));
48
+ }
49
+
50
+ /**
51
+ * Choose the verbatim catalog id a single catalog `row` should surface as in
52
+ * the picker -- NEVER fabricates an id.
53
+ *
54
+ * A direct-namespace row always keeps its own (real, direct-callable) id,
55
+ * regardless of what `pairAcrossGateways` could resolve for it -- this is
56
+ * what keeps BOTH direct rows around when two direct ids are ambiguous to
57
+ * `pairAcrossGateways` (e.g. a bare alias and a dated pinned snapshot that
58
+ * normalize to the same key): each is still a real, distinct, callable id
59
+ * and must not be dropped or silently merged.
60
+ *
61
+ * An OpenRouter-namespace row is collapsed onto a direct id only when
62
+ * `pairAcrossGateways` found exactly one (unambiguous) direct twin -- that's
63
+ * a real catalog id, safe to reuse for dedup. Otherwise: for
64
+ * `DIVERGENT_VENDORS` (direct ids don't share a string form with
65
+ * OpenRouter's -- e.g. anthropic's dash-vs-dot versioning), the row keeps
66
+ * its OpenRouter-prefixed id as-is; stripping the prefix would fabricate a
67
+ * non-direct-callable dot-form id no row actually carries. Non-divergent
68
+ * vendors keep the pre-existing strip via `toCanonicalDefault`, which is
69
+ * safe because their direct and OpenRouter ids are identical once the
70
+ * `openrouter/<vendor>/` prefix is removed (mirrors curated-models.js's
71
+ * `directFormFor` policy).
72
+ * @param {string} vendor
73
+ * @param {boolean} isDirect whether `row.id` is itself a direct-namespace id
74
+ * @param {{id:string}} row
75
+ * @param {{direct?:string, openrouter?:string}} paired
76
+ * @returns {string|null}
77
+ */
78
+ function chooseRowId(vendor, isDirect, row, paired) {
79
+ if (isDirect) { return row.id; }
80
+ if (paired.direct) { return paired.direct; }
81
+ if (!paired.openrouter) { return null; }
82
+ return DIVERGENT_VENDORS.has(vendor) ? paired.openrouter : toCanonicalDefault(paired.openrouter);
83
+ }
84
+
85
+ /**
86
+ * Dedupe `vendor`'s catalog rows across gateways into one row per logical
87
+ * model, reusing `pairAcrossGateways` (never re-deriving the pairing logic).
88
+ * Every row id is verbatim from the catalog (see `chooseRowId`) -- never
89
+ * fabricated, and a direct row is never dropped even when its OpenRouter
90
+ * twin is ambiguous.
91
+ * @param {Array<{id:string,name:string,contextLength:(number|null)}>} catalog
92
+ * @param {string} vendor
93
+ * @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
94
+ */
95
+ function buildRows(catalog, vendor) {
96
+ const byId = new Map(catalog.filter(r => r && typeof r.id === 'string').map(r => [r.id, r]));
97
+ const catalogInfo = { models: catalog };
98
+ const directPrefix = `${vendor}/`;
99
+ const orPrefix = `openrouter/${vendor}/`;
100
+
101
+ const rows = [];
102
+ const seenIds = new Set();
103
+
104
+ for (const row of vendorRowsIn(catalog, vendor)) {
105
+ const isDirect = row.id.startsWith(directPrefix);
106
+ const token = isDirect ? row.id.slice(directPrefix.length) : row.id.slice(orPrefix.length);
107
+ const paired = pairAcrossGateways(vendor, token, catalogInfo);
108
+ const chosenId = chooseRowId(vendor, isDirect, row, paired);
109
+ if (!chosenId || seenIds.has(chosenId)) { continue; }
110
+ seenIds.add(chosenId);
111
+
112
+ const orRow = paired.openrouter ? byId.get(paired.openrouter) : null;
113
+ const sourceRow = isDirect ? row : ((paired.direct && byId.get(paired.direct)) || orRow || row);
114
+
115
+ rows.push({
116
+ id: chosenId,
117
+ name: sourceRow.name,
118
+ contextLength: (sourceRow.contextLength === undefined ? null : sourceRow.contextLength),
119
+ pricePerMInput: pricePerMInputFrom(orRow),
120
+ isPreselected: false,
121
+ });
122
+ }
123
+ return rows;
124
+ }
125
+
126
+ /**
127
+ * Canonicalize `resolveTier`'s verbatim catalog-id output the same way row
128
+ * ids are built (`chooseRowId`), so it can be matched against `rows`
129
+ * exactly. `resolveTier` already prefers a real direct id when one matches
130
+ * the tier pattern (`model-tiers.js`'s `pickForTier` tries the direct
131
+ * namespace first), so this only has an effect when it fell back to an
132
+ * OpenRouter id (no matching direct-namespace row at all). For
133
+ * `DIVERGENT_VENDORS`, that OpenRouter id must stay OpenRouter-prefixed --
134
+ * stripping it would fabricate a non-direct-callable dot-form id that no row
135
+ * carries. Non-divergent vendors keep the pre-existing strip, safe because
136
+ * their direct and OpenRouter ids are identical once the prefix is removed.
137
+ * @param {string} vendor
138
+ * @param {string|null} resolved verbatim catalog id from `resolveTier`, or null
139
+ * @returns {string|null}
140
+ */
141
+ function canonicalizeResolved(vendor, resolved) {
142
+ if (!resolved) { return null; }
143
+ return DIVERGENT_VENDORS.has(vendor) ? resolved : toCanonicalDefault(resolved);
144
+ }
145
+
146
+ /**
147
+ * Decide which row (by id) should be preselected.
148
+ * Primary: `resolveTier(vendor, tier, catalog)`, canonicalized the same way
149
+ * row ids are (direct-first) so it can match a row exactly. Falls back to
150
+ * the cheapest priced row, then the first row, whenever the primary result
151
+ * is null OR (defensively) doesn't correspond to any built row.
152
+ * @param {string} vendor
153
+ * @param {string|undefined} tier
154
+ * @param {Array<{id:string}>} catalog
155
+ * @param {Array<{id:string,pricePerMInput:(number|null)}>} rows non-empty
156
+ * @returns {string} a row id from `rows`
157
+ */
158
+ function computePreselectedId(vendor, tier, catalog, rows) {
159
+ const effectiveTier = tier || getCostTier();
160
+ const resolved = resolveTier(vendor, effectiveTier, catalog);
161
+ const canonical = canonicalizeResolved(vendor, resolved);
162
+ if (canonical && rows.some(r => r.id === canonical)) { return canonical; }
163
+
164
+ const priced = rows.filter(r => r.pricePerMInput !== null);
165
+ if (priced.length > 0) {
166
+ return priced.reduce((cheapest, r) => (r.pricePerMInput < cheapest.pricePerMInput ? r : cheapest)).id;
167
+ }
168
+ return rows[0].id;
169
+ }
170
+
171
+ /** Preselected first; then price ascending; null prices last. Stable otherwise. */
172
+ function compareRows(a, b) {
173
+ if (a.isPreselected !== b.isPreselected) { return a.isPreselected ? -1 : 1; }
174
+ if (a.pricePerMInput === null && b.pricePerMInput === null) { return 0; }
175
+ if (a.pricePerMInput === null) { return 1; }
176
+ if (b.pricePerMInput === null) { return -1; }
177
+ return a.pricePerMInput - b.pricePerMInput;
178
+ }
179
+
180
+ /**
181
+ * Build the choice list for a vendor's model picker.
182
+ * @param {string} vendor e.g. 'anthropic'
183
+ * @param {{catalog?: Array<{id:string,name:string,contextLength:(number|null),pricing:(object|null)}>, tier?: string}} [options]
184
+ * @returns {{preselectedId: (string|null), rows: Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}}
185
+ */
186
+ function buildProviderDefaultChoices(vendor, options = {}) {
187
+ if (typeof vendor !== 'string' || !vendor) { return { preselectedId: null, rows: [] }; }
188
+
189
+ const catalog = Array.isArray(options.catalog) ? options.catalog : [];
190
+ const rows = buildRows(catalog, vendor);
191
+ if (rows.length === 0) { return { preselectedId: null, rows: [] }; }
192
+
193
+ const preselectedId = computePreselectedId(vendor, options.tier, catalog, rows);
194
+ for (const r of rows) { r.isPreselected = r.id === preselectedId; }
195
+ rows.sort(compareRows);
196
+
197
+ return { preselectedId, rows };
198
+ }
199
+
200
+ /**
201
+ * Apply a picker choice: store the vendor-named alias and (optionally) seed
202
+ * `config.default` on first use. Read-modify-write, NO-CLOBBER -- preserves
203
+ * an already-set `config.default` and every other existing alias/key.
204
+ *
205
+ * `chosenId` is a verbatim catalog id straight from `buildProviderDefaultChoices`
206
+ * (via `chooseRowId`/`computePreselectedId`): a real direct id, or -- for a
207
+ * `DIVERGENT_VENDORS` vendor with no direct twin -- an `openrouter/`-prefixed
208
+ * id. **Never** run `toCanonicalDefault` on a divergent vendor's id: that
209
+ * would strip the `openrouter/` prefix and fabricate a non-direct-callable
210
+ * dot-form id no row actually carries (the exact bug just fixed in Task 4's
211
+ * `chooseRowId`/`canonicalizeResolved`). Non-divergent vendors' direct and
212
+ * OpenRouter ids are identical once the prefix is stripped, so
213
+ * `toCanonicalDefault` is safe there.
214
+ * @param {string} vendor e.g. 'anthropic'
215
+ * @param {string} chosenId verbatim catalog id from the picker
216
+ * @param {{seedDefaultIfAbsent?: boolean}} [options]
217
+ * @returns {{alias: string, setAsDefault: boolean}}
218
+ */
219
+ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } = {}) {
220
+ const storedId = DIVERGENT_VENDORS.has(vendor) ? chosenId : toCanonicalDefault(chosenId);
221
+
222
+ const config = loadConfig() || {};
223
+ if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
224
+ config.aliases[vendor] = storedId;
225
+
226
+ const hasDefault = typeof config.default === 'string' && config.default.trim().length > 0;
227
+ const setAsDefault = seedDefaultIfAbsent && !hasDefault;
228
+ if (setAsDefault) { config.default = vendor; }
229
+
230
+ saveConfig(config);
231
+ return { alias: vendor, setAsDefault };
232
+ }
233
+
234
+ module.exports = { buildProviderDefaultChoices, applyProviderDefault };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Provider-default prompt flow (Part 2, Task 6/7 shared helper).
3
+ *
4
+ * Wraps the transport-agnostic picker core (`provider-default-picker.js`) in
5
+ * a single reusable flow that both the CLI (`amicus key`, Task 6) and the
6
+ * readline setup wizard (Task 7) call after a provider's API key is saved:
7
+ * build the priced choice list, either prompt for a selection (interactive)
8
+ * or silently take the tier-preselected id (non-interactive), then apply it.
9
+ *
10
+ * Transport-agnostic by injection: `ask` (a `(prompt: string) => Promise<string>`
11
+ * readline reader) and `print` (a `(line: string) => void` line writer) are
12
+ * both passed in by the caller, so this module is unit-testable without a
13
+ * real TTY and never imports `readline`/`console` itself.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const { buildProviderDefaultChoices, applyProviderDefault } = require('./provider-default-picker');
19
+ const { isDirectProvider } = require('./provider-registry');
20
+
21
+ /** Format a $/M-input price for display; `null`/`undefined` -> 'n/a'. @param {number|null|undefined} pricePerMInput */
22
+ function formatPrice(pricePerMInput) {
23
+ return (pricePerMInput === null || pricePerMInput === undefined)
24
+ ? 'n/a'
25
+ : `$${pricePerMInput.toFixed(2)}/M in`;
26
+ }
27
+
28
+ /** One numbered display line for a single choice row. @param {object} row @param {number} index 1-based */
29
+ function formatRow(row, index) {
30
+ const ctx = (row.contextLength === null || row.contextLength === undefined) ? '' : ` · ctx ${row.contextLength}`;
31
+ const recommended = row.isPreselected ? ' (recommended)' : '';
32
+ return ` ${index}) ${row.name}${ctx} · ${formatPrice(row.pricePerMInput)}${recommended}`;
33
+ }
34
+
35
+ /**
36
+ * Read one selection line, re-prompting once on an invalid non-empty entry
37
+ * before falling back to the preselected id. Empty input (bare Enter) always
38
+ * accepts the preselected id immediately, no re-prompt.
39
+ * @param {(prompt: string) => Promise<string>} ask
40
+ * @param {(line: string) => void} print
41
+ * @param {{preselectedId: string, rows: Array<{id: string}>}} choices
42
+ * @returns {Promise<string>}
43
+ */
44
+ async function promptForChoice(ask, print, choices) {
45
+ for (let attempt = 0; attempt < 2; attempt++) {
46
+ const answer = (await ask(`Pick a number (1-${choices.rows.length}, Enter for recommended): `) || '').trim();
47
+ if (answer === '') { return choices.preselectedId; }
48
+ if (/^\d+$/.test(answer)) {
49
+ const num = Number.parseInt(answer, 10);
50
+ if (num >= 1 && num <= choices.rows.length) {
51
+ return choices.rows[num - 1].id;
52
+ }
53
+ }
54
+ if (attempt === 0) { print(`Invalid choice: "${answer}".`); }
55
+ }
56
+ return choices.preselectedId;
57
+ }
58
+
59
+ /**
60
+ * Run the per-provider default-model flow: build choices, resolve a pick
61
+ * (prompted or silent), apply it, and hand back a one-line summary for the
62
+ * caller to print. Never throws for an empty/offline catalog -- callers
63
+ * should still wrap this in a try/catch (a picker bug must never abort an
64
+ * already-successful key save).
65
+ *
66
+ * Gateway providers (e.g. `openrouter`) are a graceful no-op: `openrouter` is
67
+ * the GATEWAY, not a model vendor, so `buildProviderDefaultChoices` would
68
+ * match every OR-namespaced catalog row, "recommended" would be arbitrary,
69
+ * and writing `aliases.openrouter = "<some vendor>/<model>"` would be
70
+ * nonsensical. Per-provider defaults only make sense for DIRECT model
71
+ * vendors (`provider-registry.isDirectProvider`) -- no choices are built, no
72
+ * alias is written, and `config.default` is never seeded for a gateway.
73
+ * @param {string} provider vendor name, e.g. 'anthropic'
74
+ * @param {{interactive?: boolean, ask?: (prompt: string) => Promise<string>,
75
+ * catalog?: Array<object>, print?: (line: string) => void}} [options]
76
+ * @returns {Promise<{chosenId: (string|null), setAsDefault: boolean, summaryLine: string}>}
77
+ */
78
+ async function runProviderDefaultFlow(provider, options = {}) {
79
+ const { interactive = false, ask } = options;
80
+ const catalog = Array.isArray(options.catalog) ? options.catalog : [];
81
+ const print = typeof options.print === 'function' ? options.print : () => {};
82
+
83
+ if (!isDirectProvider(provider)) {
84
+ return {
85
+ chosenId: null,
86
+ setAsDefault: false,
87
+ summaryLine: 'Per-provider defaults apply to direct provider keys (openai/anthropic/google/deepseek) -- ' +
88
+ 'models routed via OpenRouter use your overall default.',
89
+ };
90
+ }
91
+
92
+ const choices = buildProviderDefaultChoices(provider, { catalog });
93
+ if (!choices.rows || choices.rows.length === 0) {
94
+ return {
95
+ chosenId: null,
96
+ setAsDefault: false,
97
+ summaryLine: `Couldn't reach the model catalog for ${provider} — no default set. ` +
98
+ `Run \`amicus key ${provider}\` again later.`,
99
+ };
100
+ }
101
+
102
+ let chosenId = choices.preselectedId;
103
+ if (interactive && typeof ask === 'function') {
104
+ print('');
105
+ print(`Pick a default model for ${provider}:`);
106
+ choices.rows.forEach((row, i) => print(formatRow(row, i + 1)));
107
+ print('');
108
+ chosenId = await promptForChoice(ask, print, choices);
109
+ }
110
+
111
+ const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
112
+ const summaryLine = `\`amicus start --model ${provider}\` → ${chosenId}` +
113
+ (setAsDefault ? ', set as your default model' : '');
114
+
115
+ return { chosenId, setAsDefault, summaryLine };
116
+ }
117
+
118
+ module.exports = { runProviderDefaultFlow, formatPrice, formatRow };
@@ -182,15 +182,22 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
182
182
 
183
183
  /**
184
184
  * Build an alias-audit document (`models --check --json`).
185
- * @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
186
191
  */
187
- function buildAuditDoc({ stale, catalogAvailable }) {
192
+ function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
188
193
  return {
189
194
  schemaVersion: SCHEMA_VERSION,
190
195
  type: 'alias-audit',
191
196
  catalogAvailable,
192
197
  staleCount: stale.length,
193
198
  stale,
199
+ gatewayFindingsCount: gatewayFindings.length,
200
+ gatewayFindings,
194
201
  };
195
202
  }
196
203
 
@@ -6,8 +6,9 @@
6
6
  * - `toCliMessage` -> a human stderr string for the CLI
7
7
  *
8
8
  * Pure module: no I/O, no requires of launch modules (cli.js/headless.js/
9
- * mcp-server.js/etc). Additive only not imported by any launch path yet;
10
- * wiring is a later task in the #61 Integration plan.
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.
11
12
  *
12
13
  * Router error shape (src/utils/model-descriptor.js `routeError()`):
13
14
  * {kind:'error', type:'model_route_error', field, requested, reason,
@@ -15,16 +16,28 @@
15
16
  * Selection shape (`selectionRequired()`):
16
17
  * {kind:'selection_required', requested, suggestions}
17
18
  *
18
- * The router's error `reason` is a closed set of 7 values (ROUTE_ERROR_REASONS
19
- * below). A `selection_required` result has no `reason` of its own — it is
20
- * synthesized here as SELECTION_REQUIRED_REASON, kept in the same documented
21
- * REASON_TEXT map rather than invented ad hoc, so callers can treat every
22
- * rendered structured error the same way regardless of which RouteResult
23
- * produced it.
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.
24
29
  */
25
30
  'use strict';
26
31
 
27
- /** The closed set of reasons a router `error` result can carry. */
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
+ */
28
41
  const ROUTE_ERROR_REASONS = Object.freeze([
29
42
  'gateway_conflict',
30
43
  'no_openrouter_key',
@@ -51,6 +64,8 @@ const REASON_TEXT = Object.freeze({
51
64
  no_key_for_vendor: 'No API key was found for this vendor via any gateway.',
52
65
  model_not_found: 'The requested model was not found in the catalog.',
53
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.",
54
69
  [SELECTION_REQUIRED_REASON]: 'Multiple models match your request; a specific one must be selected.',
55
70
  });
56
71
 
@@ -63,6 +78,8 @@ const FIX_HINTS = Object.freeze({
63
78
  no_key_for_vendor: 'Add a provider key or an OpenRouter key.',
64
79
  model_not_found: 'Run `amicus models --refresh`, or pass --no-validate-model.',
65
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.',
66
83
  [SELECTION_REQUIRED_REASON]: 'Pick one of the suggestions below, or narrow the model id.',
67
84
  });
68
85