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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Route-launch views (#61 gateway routing integration, Task 4.2).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* stubbable api-key-store / auth-json /
|
|
4
|
+
* Read-only helpers consumed by resolveRouteForLaunch, which IS wired into
|
|
5
|
+
* live launch paths (start-helpers.js, mcp-server.js, sidecar/fanout-validate.js).
|
|
6
|
+
* Pure-ish: all I/O goes through the stubbable api-key-store / auth-json /
|
|
7
|
+
* model-catalog modules.
|
|
7
8
|
*/
|
|
8
9
|
'use strict';
|
|
9
10
|
|
|
@@ -52,12 +53,20 @@ const ROUTE_VERSION = 1;
|
|
|
52
53
|
/**
|
|
53
54
|
* Build up to ~6 labeled alternatives for a `selection_required` RouteResult
|
|
54
55
|
* (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
|
|
55
|
-
* descriptor plus the live keys/catalogInfo the caller already
|
|
56
|
+
* descriptor plus the live keys/catalogInfo/gatewayIds the caller already
|
|
57
|
+
* assembled.
|
|
56
58
|
*
|
|
57
59
|
* Two categories, in order:
|
|
58
60
|
* 1. The SAME model via OpenRouter — only when an OpenRouter key is present
|
|
59
|
-
* AND the OR-namespaced id
|
|
60
|
-
*
|
|
61
|
+
* AND the OR-namespaced id is actually present in the catalog (never
|
|
62
|
+
* suggest an id we can't confirm exists). For divergent vendors (e.g.
|
|
63
|
+
* Anthropic) `descriptor.model` may be the DASH-form direct id, so a
|
|
64
|
+
* reconstructed `openrouter/<vendor>/<model>` would never match the
|
|
65
|
+
* catalog's dot-form OR id -- when the caller's `gatewayIds.openrouter`
|
|
66
|
+
* is available (the catalog-correct form), it is used instead of
|
|
67
|
+
* reconstructing. Falls back to reconstruction when `gatewayIds` is
|
|
68
|
+
* absent (non-alias / full-id / non-divergent requests), so behavior
|
|
69
|
+
* there is unchanged.
|
|
61
70
|
* 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
|
|
62
71
|
* `<vendor>/`, excluding the requested id itself and excluding any
|
|
63
72
|
* `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
|
|
@@ -68,9 +77,13 @@ const ROUTE_VERSION = 1;
|
|
|
68
77
|
* openrouter-literal — both carry vendor/model)
|
|
69
78
|
* @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
|
|
70
79
|
* @param {{models: Array<{id:string}>}} catalogInfo
|
|
80
|
+
* @param {{direct?: string, openrouter?: string}} [gatewayIds] the same
|
|
81
|
+
* per-gateway id map resolveRouteForLaunch threads through resolveRoute
|
|
82
|
+
* (Task 3's bridge for divergent curated aliases); absent for non-alias /
|
|
83
|
+
* full-id / non-divergent requests
|
|
71
84
|
* @returns {Array<{model:string, gateway:string, note:string}>}
|
|
72
85
|
*/
|
|
73
|
-
function buildSuggestions(descriptor, keys, catalogInfo) {
|
|
86
|
+
function buildSuggestions(descriptor, keys, catalogInfo, gatewayIds) {
|
|
74
87
|
const suggestions = [];
|
|
75
88
|
const vendor = descriptor && descriptor.vendor;
|
|
76
89
|
const model = descriptor && descriptor.model;
|
|
@@ -80,7 +93,7 @@ function buildSuggestions(descriptor, keys, catalogInfo) {
|
|
|
80
93
|
const requestedDirectId = `${vendor}/${model}`;
|
|
81
94
|
|
|
82
95
|
if (keys && keys.openrouter) {
|
|
83
|
-
const orId = `openrouter/${vendor}/${model}`;
|
|
96
|
+
const orId = (gatewayIds && gatewayIds.openrouter) || `openrouter/${vendor}/${model}`;
|
|
84
97
|
if (models.some(m => m && m.id === orId)) {
|
|
85
98
|
suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
|
|
86
99
|
}
|
|
@@ -137,14 +150,110 @@ function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
|
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Bridge: alias -> descriptor -> resolveRoute (Task 4.4; gatewayIds bridging
|
|
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.
|
|
242
|
+
*
|
|
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.
|
|
253
|
+
*
|
|
254
|
+
* Assembles live key/catalog state and delegates the actual decision to the
|
|
255
|
+
* pure gateway-router. Wired into start-helpers.js, mcp-server.js, and
|
|
256
|
+
* sidecar/fanout-validate.js.
|
|
148
257
|
* @param {{model:string, gatewayMode:string, source:string, allowSelection?:boolean, validateModel?:boolean}} opts
|
|
149
258
|
* @returns {Promise<object>} RouteResult (resolved | selection_required | error)
|
|
150
259
|
*/
|
|
@@ -155,23 +264,34 @@ async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelectio
|
|
|
155
264
|
const { parseDescriptor } = require('./model-descriptor');
|
|
156
265
|
const { resolveRoute } = require('./gateway-router');
|
|
157
266
|
const aliases = getEffectiveAliases();
|
|
158
|
-
const
|
|
159
|
-
|
|
267
|
+
const isAlias = typeof model === 'string' && !model.includes('/') && !!aliases[model];
|
|
268
|
+
let concrete = isAlias ? aliases[model] : model;
|
|
160
269
|
const keys = buildLaunchKeys();
|
|
161
|
-
// Skip the catalog fetch entirely under --no-validate-model
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
// own `=== false` guard: any other value (incl. an omitted flag) still fetches,
|
|
167
|
-
// so a caller can never skip the fetch while the gate still classifies against it.
|
|
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.
|
|
168
275
|
const catalogInfo = validateModel === false ? { models: [], lastRefreshError: null } : await getRouteCatalogInfo();
|
|
169
|
-
let
|
|
276
|
+
let gatewayIds;
|
|
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);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const descriptor = parseDescriptor(concrete, { aliases });
|
|
289
|
+
let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo, gatewayIds });
|
|
170
290
|
if (result.kind === 'resolved') {
|
|
171
291
|
result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };
|
|
172
292
|
result = maybeMigrationNotice({ result, descriptor, gatewayMode, keys });
|
|
173
293
|
} else if (result.kind === 'selection_required') {
|
|
174
|
-
result.suggestions = buildSuggestions(descriptor, keys, catalogInfo);
|
|
294
|
+
result.suggestions = buildSuggestions(descriptor, keys, catalogInfo, gatewayIds);
|
|
175
295
|
}
|
|
176
296
|
return result;
|
|
177
297
|
}
|
|
@@ -123,7 +123,60 @@ async function resolveLaunchModel(args) {
|
|
|
123
123
|
process.exit(1);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Existing-user one-time onboarding offer (Part 2, Task 9). Prints a single
|
|
128
|
+
* non-blocking notice line pointing configured-but-not-yet-onboarded users at
|
|
129
|
+
* the per-provider cost-aware default picker (`amicus key <provider>`, Task
|
|
130
|
+
* 6). This is a PRINTED LINE, never an interactive prompt -- it must never
|
|
131
|
+
* wait on stdin.
|
|
132
|
+
*
|
|
133
|
+
* Fires only when ALL of:
|
|
134
|
+
* - the session is interactive (a real TTY, and not --json/--quiet)
|
|
135
|
+
* - the user hasn't already seen this notice (`hasTierOnboarded()` false)
|
|
136
|
+
* - at least one DIRECT provider (openai/anthropic/google/deepseek, via
|
|
137
|
+
* `provider-registry.listDirectProviders`) has a key configured
|
|
138
|
+
* - the user hasn't already used the picker (no vendor-named alias set --
|
|
139
|
+
* no key of `config.aliases` matches a direct-provider id)
|
|
140
|
+
*
|
|
141
|
+
* The flag is only ever set when the notice actually printed. If any gate
|
|
142
|
+
* fails, this is a no-op AND the flag is left unset -- a user who later adds
|
|
143
|
+
* a direct key (or a non-interactive run that becomes interactive) can still
|
|
144
|
+
* see the notice once. Wrapped end-to-end in try/catch: a bug here must never
|
|
145
|
+
* break the command it's attached to.
|
|
146
|
+
* @param {object} [args] parsed CLI args (checked for --json/--quiet to gate interactivity)
|
|
147
|
+
*/
|
|
148
|
+
function maybeOfferProviderDefaults(args = {}) {
|
|
149
|
+
try {
|
|
150
|
+
const interactive = !!process.stdin.isTTY && !args.json && !args.quiet;
|
|
151
|
+
if (!interactive) { return; }
|
|
152
|
+
|
|
153
|
+
const { hasTierOnboarded, markTierOnboarded, loadConfig } = require('./config');
|
|
154
|
+
if (hasTierOnboarded()) { return; }
|
|
155
|
+
|
|
156
|
+
const { listDirectProviders } = require('./provider-registry');
|
|
157
|
+
const { readApiKeys } = require('./api-key-store');
|
|
158
|
+
const directProviders = listDirectProviders();
|
|
159
|
+
const apiKeys = readApiKeys();
|
|
160
|
+
const hasDirectKey = directProviders.some((p) => apiKeys[p]);
|
|
161
|
+
if (!hasDirectKey) { return; }
|
|
162
|
+
|
|
163
|
+
const config = loadConfig() || {};
|
|
164
|
+
const aliases = (config.aliases && typeof config.aliases === 'object') ? config.aliases : {};
|
|
165
|
+
const hasVendorAlias = directProviders.some((p) => Object.prototype.hasOwnProperty.call(aliases, p));
|
|
166
|
+
if (hasVendorAlias) { return; }
|
|
167
|
+
|
|
168
|
+
process.stdout.write(
|
|
169
|
+
'Tip: run `amicus key <provider>` to pick a cost-aware default model per provider ' +
|
|
170
|
+
'(avoids defaulting to the priciest flagship).\n'
|
|
171
|
+
);
|
|
172
|
+
markTierOnboarded();
|
|
173
|
+
} catch (_err) {
|
|
174
|
+
// best-effort: never break the command this is attached to
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
126
178
|
module.exports = {
|
|
127
179
|
resolveLaunchModel,
|
|
128
180
|
deriveAlias,
|
|
181
|
+
maybeOfferProviderDefaults,
|
|
129
182
|
};
|