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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/electron/ipc-setup.js +33 -0
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-keys-script.js +8 -1
- package/electron/setup-ui-provider-default.js +101 -0
- package/electron/setup-ui-styles.js +13 -1
- package/electron/setup-ui.js +5 -1
- package/package.json +1 -1
- package/src/cli-handlers-run.js +9 -1
- package/src/cli-handlers.js +40 -0
- package/src/sidecar/setup.js +80 -11
- package/src/utils/config.js +59 -0
- package/src/utils/curated-models.js +4 -2
- package/src/utils/model-tiers.js +120 -0
- package/src/utils/provider-default-picker.js +234 -0
- package/src/utils/provider-default-prompt.js +118 -0
- package/src/utils/route-launch.js +116 -36
- package/src/utils/start-helpers.js +53 -0
|
@@ -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
|
|
155
|
-
* Resolves a raw model string to a Descriptor —
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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'
|
|
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
|
|
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
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 };
|