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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +44 -0
- package/README.md +2 -2
- package/electron/ipc-setup.js +33 -0
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-aliases.js +1 -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 +2 -1
- package/src/cli-handlers-run.js +9 -1
- package/src/cli-handlers.js +40 -0
- package/src/cli.js +4 -0
- package/src/sidecar/models.js +39 -17
- package/src/sidecar/setup.js +80 -11
- package/src/utils/config.js +59 -0
- package/src/utils/curated-models.js +69 -5
- package/src/utils/gateway-route-audit.js +103 -0
- package/src/utils/gateway-route-catalog.js +92 -0
- package/src/utils/gateway-router.js +21 -5
- package/src/utils/model-fetcher.js +11 -5
- 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/result-schema.js +9 -2
- package/src/utils/route-error.js +26 -9
- package/src/utils/route-launch.js +147 -27
- 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 };
|
|
@@ -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
|
-
*
|
|
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
|
|
package/src/utils/route-error.js
CHANGED
|
@@ -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).
|
|
10
|
-
*
|
|
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
|
|
19
|
-
* below
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
/**
|
|
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
|
|