amicus 3.1.1 → 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 };
@@ -149,28 +149,107 @@ function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
149
149
  return result;
150
150
  }
151
151
 
152
+ /**
153
+ * Comparison-only key for a gateway-native id: strip a leading `openrouter/`
154
+ * prefix, lowercase, unify dot/dash separators. Lets a divergent vendor's
155
+ * dash-form direct id, dot-form OpenRouter id, and a stale/reformatted
156
+ * variant (e.g. toDefaultAliases()'s dotted default) collapse to one key
157
+ * when they name the same model. Never used to construct/emit an id.
158
+ * @param {string} id @returns {string|null}
159
+ */
160
+ function normalizeForModelIndex(id) {
161
+ if (typeof id !== 'string' || !id) { return null; }
162
+ const rest = id.startsWith('openrouter/') ? id.slice('openrouter/'.length) : id;
163
+ return rest.toLowerCase().replace(/\./g, '-');
164
+ }
165
+
166
+ /**
167
+ * By-model index of every curated route pair (Task 3 Part 2 / spec D2): each
168
+ * `{direct?, openrouter}` pair from toGatewayRoutes() is indexed under the
169
+ * normalized form of BOTH its values, so a lookup BY RESOLVED MODEL (not
170
+ * alias name) finds the pair regardless of which alias produced it or which
171
+ * gateway-native form the caller's id is in.
172
+ * @returns {Object<string,{direct?:string, openrouter?:string}>}
173
+ */
174
+ function buildGatewayRoutesByModel() {
175
+ const { toGatewayRoutes } = require('./curated-models');
176
+ const byModel = {};
177
+ for (const pair of Object.values(toGatewayRoutes())) {
178
+ const directKey = normalizeForModelIndex(pair.direct);
179
+ const orKey = normalizeForModelIndex(pair.openrouter);
180
+ if (directKey) { byModel[directKey] = pair; }
181
+ if (orKey) { byModel[orKey] = pair; }
182
+ }
183
+ return byModel;
184
+ }
185
+
186
+ /**
187
+ * @param {string} resolvedId direct (`vendor/model`) or OR-prefixed
188
+ * (`openrouter/vendor/model`) gateway-native id
189
+ * @returns {{vendor:string, bareModel:string}|null}
190
+ */
191
+ function splitVendorAndModel(resolvedId) {
192
+ if (typeof resolvedId !== 'string') { return null; }
193
+ const rest = resolvedId.startsWith('openrouter/') ? resolvedId.slice('openrouter/'.length) : resolvedId;
194
+ const idx = rest.indexOf('/');
195
+ if (idx <= 0 || idx === rest.length - 1) { return null; }
196
+ return { vendor: rest.slice(0, idx), bareModel: rest.slice(idx + 1) };
197
+ }
198
+
199
+ /**
200
+ * Resolve `{direct?, openrouter?}` for `resolvedId`, for ANY alias — curated
201
+ * default, user override, or vendor alias (Task 3 Part 2 / spec D2; retires
202
+ * Part 1's curated-default-NAME-only guard). Two-step:
203
+ * 1. By-model curated lookup (buildGatewayRoutesByModel()) — covers curated
204
+ * defaults byte-identically to Part 1, plus any alias resolving to the
205
+ * same underlying curated model.
206
+ * 2. Catalog pairing fallback: for a DIVERGENT vendor not covered by (1),
207
+ * ask the live catalog (gateway-route-catalog's pairAcrossGateways,
208
+ * Task 5) to pair the two forms. Non-divergent vendors skip this — their
209
+ * ids already match across gateways, so `executableFor`'s fallback is
210
+ * already correct with no gatewayIds.
211
+ * FAIL-OPEN: any miss/malformed-input/thrown-error resolves to `undefined`
212
+ * (no gatewayIds) — never raises, never blocks a launch.
213
+ * @param {string} resolvedId @param {{models: Array}} catalogInfo
214
+ * @returns {{direct?:string, openrouter?:string}|undefined}
215
+ */
216
+ function resolveGatewayIdsByModel(resolvedId, catalogInfo) {
217
+ try {
218
+ const key = normalizeForModelIndex(resolvedId);
219
+ const byModel = key ? buildGatewayRoutesByModel() : null;
220
+ if (byModel && byModel[key]) { return byModel[key]; }
221
+
222
+ const split = splitVendorAndModel(resolvedId);
223
+ if (!split) { return undefined; }
224
+ const { DIVERGENT_VENDORS } = require('./curated-models');
225
+ if (!DIVERGENT_VENDORS.has(split.vendor)) { return undefined; }
226
+
227
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
228
+ const paired = pairAcrossGateways(split.vendor, split.bareModel, catalogInfo);
229
+ return (paired && (paired.direct || paired.openrouter)) ? paired : undefined;
230
+ } catch (_err) {
231
+ return undefined; // fail-open: a lookup error must never block the launch.
232
+ }
233
+ }
234
+
152
235
  /**
153
236
  * 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.
237
+ * Task 3, generalized by-model in Part 2 Task 3 / spec D2).
238
+ * Resolves a raw model string to a Descriptor — a known no-slash alias (per
239
+ * getEffectiveAliases()) has its concrete id parsed instead, so an alias
240
+ * pointing at an `openrouter/...` value is an explicit force-OR literal
241
+ * while one pointing at a bare `vendor/model` is policy-routed normally.
160
242
  *
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).
243
+ * For ANY alias, the resolved concrete id is looked up BY MODEL via
244
+ * resolveGatewayIdsByModel() and threaded through as `gatewayIds` — the
245
+ * descriptor is then re-parsed from the gateway-native form (not the raw
246
+ * alias string, which can be the wrong per-gateway form for a divergent
247
+ * vendor, e.g. Anthropic's dash vs. OpenRouter's dot ids). Preserves Part
248
+ * 1's force-OR contract: an alias whose ORIGINAL value is an explicit
249
+ * `openrouter/...` literal prefers `gatewayIds.openrouter` (else falls back
250
+ * to that original value); any other alias value prefers `gatewayIds.direct`
251
+ * (else `.openrouter`) as before. Uncovered models get no gatewayIds
252
+ * (fail-open); full-id/non-alias inputs are unaffected.
174
253
  *
175
254
  * Assembles live key/catalog state and delegates the actual decision to the
176
255
  * pure gateway-router. Wired into start-helpers.js, mcp-server.js, and
@@ -179,33 +258,34 @@ function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
179
258
  * @returns {Promise<object>} RouteResult (resolved | selection_required | error)
180
259
  */
181
260
  async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelection, validateModel }) {
182
- // Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router' | './curated-models', ...)
261
+ // Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router', ...)
183
262
  // can intercept them per-test, matching the pattern already used above for model-catalog.
184
- const { getEffectiveAliases, getDefaultAliases } = require('./config');
263
+ const { getEffectiveAliases } = require('./config');
185
264
  const { parseDescriptor } = require('./model-descriptor');
186
265
  const { resolveRoute } = require('./gateway-router');
187
- const { toGatewayRoutes } = require('./curated-models');
188
266
  const aliases = getEffectiveAliases();
189
267
  const isAlias = typeof model === 'string' && !model.includes('/') && !!aliases[model];
190
268
  let concrete = isAlias ? aliases[model] : model;
269
+ const keys = buildLaunchKeys();
270
+ // Skip the catalog fetch entirely under --no-validate-model (catalogGate
271
+ // short-circuits without consulting catalogInfo when validateModel===false,
272
+ // so fetching would be wasted network/latency). Moved up from after
273
+ // descriptor parsing because the catalog-pairing fallback below needs it;
274
+ // otherwise independent of the descriptor/alias, so no other effect.
275
+ const catalogInfo = validateModel === false ? { models: [], lastRefreshError: null } : await getRouteCatalogInfo();
191
276
  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;
277
+ if (isAlias) {
278
+ const originalConcrete = concrete; // pre-gatewayIds alias value; may itself be an explicit `openrouter/...` literal
279
+ gatewayIds = resolveGatewayIdsByModel(concrete, catalogInfo);
280
+ if (gatewayIds) {
281
+ // Force-OR contract (Part 1): an explicit openrouter/... alias value
282
+ // stays on OpenRouter; only a bare vendor/model value prefers direct.
283
+ concrete = originalConcrete.startsWith('openrouter/')
284
+ ? (gatewayIds.openrouter || originalConcrete)
285
+ : (gatewayIds.direct || gatewayIds.openrouter);
197
286
  }
198
287
  }
199
288
  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
289
  let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo, gatewayIds });
210
290
  if (result.kind === 'resolved') {
211
291
  result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };