amicus 4.1.1 → 4.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 +20 -1
- package/bin/amicus.js +10 -0
- package/electron/ipc-setup-local.js +109 -0
- package/electron/ipc-setup.js +14 -2
- package/electron/preload-setup.js +3 -1
- package/electron/setup-ui-local-script.js +114 -0
- package/electron/setup-ui-local.js +75 -0
- package/electron/setup-ui-styles.js +15 -1
- package/electron/setup-ui.js +6 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +12 -211
- package/src/cli-handlers-doctor.js +12 -0
- package/src/cli-handlers-init.js +83 -0
- package/src/cli-handlers-key-local.js +144 -0
- package/src/cli-handlers-provider.js +212 -0
- package/src/cli-handlers.js +31 -3
- package/src/cli.js +21 -0
- package/src/sidecar/setup-local.js +75 -0
- package/src/sidecar/setup.js +101 -6
- package/src/utils/api-key-store.js +12 -62
- package/src/utils/claude-register.js +267 -0
- package/src/utils/config.js +82 -10
- package/src/utils/doctor-local-providers-check.js +62 -0
- package/src/utils/doctor-summary.js +33 -0
- package/src/utils/env-loader.js +13 -0
- package/src/utils/env-raw-store.js +111 -0
- package/src/utils/gateway-router.js +65 -2
- package/src/utils/lifecycle.js +2 -1
- package/src/utils/local-probe.js +109 -0
- package/src/utils/local-providers.js +141 -0
- package/src/utils/model-catalog.js +6 -2
- package/src/utils/model-fetcher.js +11 -1
- package/src/utils/pricing.js +23 -9
- package/src/utils/provider-default-picker.js +17 -2
- package/src/utils/provider-default-prompt.js +7 -4
- package/src/utils/quick-picks.js +35 -12
- package/src/utils/route-error.js +10 -3
- package/src/utils/route-launch.js +8 -65
- package/src/utils/route-suggestions.js +85 -0
package/src/utils/quick-picks.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const { getFamilies, toDefaultAliases, toCanonicalDefault } = require('./curated-models');
|
|
13
|
+
const { getFamilies, toDefaultAliases, toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
|
|
14
14
|
|
|
15
15
|
const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
|
|
16
16
|
|
|
@@ -45,7 +45,7 @@ function pickCurrent(catalog, nsPrefix, vendorPath, idPattern) {
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* @param {Array<{id:string}>} catalog
|
|
48
|
-
* @returns {Array<{alias,label,blurb,source:'live'|'fallback',routes:Object<string,string>}>}
|
|
48
|
+
* @returns {Array<{alias,label,blurb,vendorPath,source:'live'|'fallback',routes:Object<string,string>}>}
|
|
49
49
|
* `routes` may be empty if a family defines no fallback and the catalog has no match.
|
|
50
50
|
*/
|
|
51
51
|
function resolveQuickPicks(catalog) {
|
|
@@ -60,30 +60,53 @@ function resolveQuickPicks(catalog) {
|
|
|
60
60
|
if (direct) { routes[p] = direct; live = true; }
|
|
61
61
|
else if (f.fallback[p]) { routes[p] = f.fallback[p]; }
|
|
62
62
|
}
|
|
63
|
-
return { alias: f.alias, label: f.label, blurb: f.blurb, routes,
|
|
63
|
+
return { alias: f.alias, label: f.label, blurb: f.blurb, vendorPath: f.vendorPath, routes,
|
|
64
64
|
source: live ? 'live' : 'fallback' };
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The single route value a wizard may STORE for a resolved quick pick.
|
|
70
|
+
*
|
|
71
|
+
* For a direct-capable vendor the OpenRouter pick is canonicalised to bare
|
|
72
|
+
* `vendor/model` so it stays direct-first via the gateway router — otherwise a
|
|
73
|
+
* fresh `amicus setup` with a live catalog would silently defeat the
|
|
74
|
+
* direct-first default `toDefaultAliases()` establishes.
|
|
75
|
+
*
|
|
76
|
+
* For a DIVERGENT vendor the direct id is never DERIVED from the OpenRouter
|
|
77
|
+
* one: they are different strings, not differently prefixed (OpenRouter serves
|
|
78
|
+
* `anthropic/claude-opus-4.8`, the direct API `anthropic/claude-opus-4-8`).
|
|
79
|
+
* Stripping the prefix there fabricates an id the direct API rejects, which
|
|
80
|
+
* `amicus doctor` then reports as a stale alias. The row's own direct route is
|
|
81
|
+
* used verbatim, falling back to the intact `openrouter/` form when the
|
|
82
|
+
* catalog offered no direct pick. Mirrors the guard already used at
|
|
83
|
+
* `provider-default-picker.js:82,143,220`.
|
|
84
|
+
* @param {{vendorPath?:string, routes?:Object<string,string>}} pick
|
|
85
|
+
* @returns {string|undefined}
|
|
86
|
+
*/
|
|
87
|
+
function toStorableRoute(pick) {
|
|
88
|
+
const routes = (pick && pick.routes) || {};
|
|
89
|
+
if (pick && DIVERGENT_VENDORS.has(pick.vendorPath)) {
|
|
90
|
+
return routes[pick.vendorPath] || routes.openrouter;
|
|
91
|
+
}
|
|
92
|
+
return toCanonicalDefault(routes.openrouter || Object.values(routes)[0]);
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
/**
|
|
69
96
|
* Seed map for fresh configs: static defaults overlaid with live family
|
|
70
|
-
* routes (cardless aliases stay pinned).
|
|
71
|
-
*
|
|
72
|
-
* lands as bare `vendor/model` (direct-first via the gateway router)
|
|
73
|
-
* instead of the raw `openrouter/<vendor>/<rest>` pick — otherwise a fresh
|
|
74
|
-
* `amicus setup` with a live catalog would silently defeat the direct-first
|
|
75
|
-
* default `toDefaultAliases()` establishes. Gateway-only vendors are
|
|
76
|
-
* returned unchanged by `toCanonicalDefault`.
|
|
97
|
+
* routes (cardless aliases stay pinned). See `toStorableRoute` for why the
|
|
98
|
+
* overlaid value is not a raw prefix strip.
|
|
77
99
|
* @returns {Object<string,string>}
|
|
78
100
|
*/
|
|
79
101
|
function toLiveSeedAliases(catalog) {
|
|
80
102
|
const seeds = toDefaultAliases();
|
|
81
103
|
for (const r of resolveQuickPicks(catalog || [])) {
|
|
82
104
|
if (r.source === 'live' && r.routes.openrouter) {
|
|
83
|
-
|
|
105
|
+
const stored = toStorableRoute(r);
|
|
106
|
+
if (stored) { seeds[r.alias] = stored; }
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
return seeds;
|
|
87
110
|
}
|
|
88
111
|
|
|
89
|
-
module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases };
|
|
112
|
+
module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
|
package/src/utils/route-error.js
CHANGED
|
@@ -66,6 +66,9 @@ const REASON_TEXT = Object.freeze({
|
|
|
66
66
|
invalid_descriptor: 'The model identifier could not be parsed.',
|
|
67
67
|
direct_unavailable: "This model isn't available on the vendor's direct API; use OpenRouter or a different model.",
|
|
68
68
|
openrouter_unavailable: "This model isn't on OpenRouter; use --gateway direct or a different model.",
|
|
69
|
+
no_openrouter_route: "Local providers can't be routed through OpenRouter.",
|
|
70
|
+
no_local_key: 'No bearer token is configured for this local provider.',
|
|
71
|
+
local_endpoint_unreachable: "The local endpoint didn't respond.",
|
|
69
72
|
[SELECTION_REQUIRED_REASON]: 'Multiple models match your request; a specific one must be selected.',
|
|
70
73
|
});
|
|
71
74
|
|
|
@@ -80,6 +83,9 @@ const FIX_HINTS = Object.freeze({
|
|
|
80
83
|
invalid_descriptor: 'Use a vendor/model id or a configured alias.',
|
|
81
84
|
direct_unavailable: 'Drop --gateway direct (use auto or --gateway openrouter), or pick a different model.',
|
|
82
85
|
openrouter_unavailable: 'Use --gateway direct, or pick a different model.',
|
|
86
|
+
no_openrouter_route: 'Drop --gateway openrouter — local endpoints route direct.',
|
|
87
|
+
no_local_key: 'Add one with `amicus key <id> <token>`.',
|
|
88
|
+
local_endpoint_unreachable: 'Start the local server, or pass --no-validate-model to skip the reachability check.',
|
|
83
89
|
[SELECTION_REQUIRED_REASON]: 'Pick one of the suggestions below, or narrow the model id.',
|
|
84
90
|
});
|
|
85
91
|
|
|
@@ -139,7 +145,7 @@ function toCliMessage(result) {
|
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
147
|
|
|
142
|
-
const hint = FIX_HINTS[err.reason];
|
|
148
|
+
const hint = (result && result.hint) || FIX_HINTS[err.reason];
|
|
143
149
|
if (hint) { lines.push(hint); }
|
|
144
150
|
|
|
145
151
|
return lines.join('\n');
|
|
@@ -156,7 +162,8 @@ function toCliMessage(result) {
|
|
|
156
162
|
*/
|
|
157
163
|
function toErrorDocFields(result) {
|
|
158
164
|
const { ERROR_CODES } = require('./error-doc');
|
|
159
|
-
|
|
165
|
+
// v4.2: a missing local bearer is key-shaped, not model-shaped (D12).
|
|
166
|
+
const KEY_REASONS = ['no_openrouter_key', 'no_direct_key', 'no_key_for_vendor', 'no_local_key'];
|
|
160
167
|
const err = toStructuredError(result);
|
|
161
168
|
const sentence = REASON_TEXT[err.reason] || `Model routing error (${err.reason}).`;
|
|
162
169
|
let message = err.requested ? `${sentence} (requested "${err.requested}")` : sentence;
|
|
@@ -166,7 +173,7 @@ function toErrorDocFields(result) {
|
|
|
166
173
|
return {
|
|
167
174
|
code: KEY_REASONS.includes(err.reason) ? ERROR_CODES.MISSING_KEY : ERROR_CODES.BAD_MODEL,
|
|
168
175
|
message,
|
|
169
|
-
hint: FIX_HINTS[err.reason] || null,
|
|
176
|
+
hint: (result && result.hint) || FIX_HINTS[err.reason] || null,
|
|
170
177
|
};
|
|
171
178
|
}
|
|
172
179
|
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const { readApiKeys } = require('./api-key-store');
|
|
12
12
|
const { readAuthJsonKeys } = require('./auth-json');
|
|
13
13
|
const { KNOWN_PROVIDERS } = require('./provider-registry');
|
|
14
|
+
const { buildSuggestions, applySuggestions } = require('./route-suggestions');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Per-provider key presence across BOTH sources: env/.env (readApiKeys) and
|
|
@@ -50,69 +51,6 @@ async function getRouteCatalogInfo() {
|
|
|
50
51
|
/** Module version stamped onto `resolved` results' provenance (carry-forward). */
|
|
51
52
|
const ROUTE_VERSION = 1;
|
|
52
53
|
|
|
53
|
-
/**
|
|
54
|
-
* Build up to ~6 labeled alternatives for a `selection_required` RouteResult
|
|
55
|
-
* (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
|
|
56
|
-
* descriptor plus the live keys/catalogInfo/gatewayIds the caller already
|
|
57
|
-
* assembled.
|
|
58
|
-
*
|
|
59
|
-
* Two categories, in order:
|
|
60
|
-
* 1. The SAME model via OpenRouter — only when an OpenRouter key is present
|
|
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.
|
|
70
|
-
* 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
|
|
71
|
-
* `<vendor>/`, excluding the requested id itself and excluding any
|
|
72
|
-
* `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
|
|
73
|
-
* only when vendor === 'openrouter' and are filtered out defensively).
|
|
74
|
-
*
|
|
75
|
-
* @param {{vendor?: string, model?: string}} descriptor parsed Descriptor for
|
|
76
|
-
* the request that produced the selection_required (canonical or
|
|
77
|
-
* openrouter-literal — both carry vendor/model)
|
|
78
|
-
* @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
|
|
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
|
|
84
|
-
* @returns {Array<{model:string, gateway:string, note:string}>}
|
|
85
|
-
*/
|
|
86
|
-
function buildSuggestions(descriptor, keys, catalogInfo, gatewayIds) {
|
|
87
|
-
const suggestions = [];
|
|
88
|
-
const vendor = descriptor && descriptor.vendor;
|
|
89
|
-
const model = descriptor && descriptor.model;
|
|
90
|
-
if (!vendor || !model) { return suggestions; }
|
|
91
|
-
|
|
92
|
-
const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
|
|
93
|
-
const requestedDirectId = `${vendor}/${model}`;
|
|
94
|
-
|
|
95
|
-
if (keys && keys.openrouter) {
|
|
96
|
-
const orId = (gatewayIds && gatewayIds.openrouter) || `openrouter/${vendor}/${model}`;
|
|
97
|
-
if (models.some(m => m && m.id === orId)) {
|
|
98
|
-
suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const nsPrefix = `${vendor}/`;
|
|
103
|
-
const sameVendor = models.filter(m =>
|
|
104
|
-
m && typeof m.id === 'string' &&
|
|
105
|
-
m.id.startsWith(nsPrefix) &&
|
|
106
|
-
!m.id.startsWith('openrouter/') &&
|
|
107
|
-
m.id !== requestedDirectId
|
|
108
|
-
).slice(0, 5);
|
|
109
|
-
for (const m of sameVendor) {
|
|
110
|
-
suggestions.push({ model: m.id, gateway: 'direct', note: `${vendor} model` });
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return suggestions.slice(0, 6);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
54
|
/**
|
|
117
55
|
* One-time per-vendor notice when auto-routing migrates a both-key holder off
|
|
118
56
|
* OpenRouter onto direct (#61 Task 5.1 — visible-migration guarantee: never
|
|
@@ -286,12 +224,17 @@ async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelectio
|
|
|
286
224
|
}
|
|
287
225
|
}
|
|
288
226
|
const descriptor = parseDescriptor(concrete, { aliases });
|
|
289
|
-
|
|
227
|
+
// v4.2: local-provider inputs (assembled in local-providers.js — 300-line gate).
|
|
228
|
+
const { getLocalProviders, resolveLocalRouteInputs } = require('./local-providers');
|
|
229
|
+
const { localProviders, localLive } =
|
|
230
|
+
await resolveLocalRouteInputs(descriptor, { validateModel, providers: getLocalProviders() });
|
|
231
|
+
let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel,
|
|
232
|
+
keys, catalogInfo, gatewayIds, localProviders, localLive });
|
|
290
233
|
if (result.kind === 'resolved') {
|
|
291
234
|
result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };
|
|
292
235
|
result = maybeMigrationNotice({ result, descriptor, gatewayMode, keys });
|
|
293
236
|
} else if (result.kind === 'selection_required') {
|
|
294
|
-
result
|
|
237
|
+
applySuggestions(result, { descriptor, keys, catalogInfo, gatewayIds, localProviders });
|
|
295
238
|
}
|
|
296
239
|
return result;
|
|
297
240
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Suggestion-building for `selection_required` RouteResults, split out of
|
|
5
|
+
* route-launch.js (B2/D3 — the 300-line gate). Pure: no I/O, no requires.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build up to ~6 labeled alternatives for a `selection_required` RouteResult
|
|
10
|
+
* (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
|
|
11
|
+
* descriptor plus the live keys/catalogInfo/gatewayIds the caller already
|
|
12
|
+
* assembled.
|
|
13
|
+
*
|
|
14
|
+
* Two categories, in order:
|
|
15
|
+
* 1. The SAME model via OpenRouter — only when an OpenRouter key is present
|
|
16
|
+
* AND the OR-namespaced id is actually present in the catalog (never
|
|
17
|
+
* suggest an id we can't confirm exists). For divergent vendors (e.g.
|
|
18
|
+
* Anthropic) `descriptor.model` may be the DASH-form direct id, so a
|
|
19
|
+
* reconstructed `openrouter/<vendor>/<model>` would never match the
|
|
20
|
+
* catalog's dot-form OR id -- when the caller's `gatewayIds.openrouter`
|
|
21
|
+
* is available (the catalog-correct form), it is used instead of
|
|
22
|
+
* reconstructing. Falls back to reconstruction when `gatewayIds` is
|
|
23
|
+
* absent (non-alias / full-id / non-divergent requests), so behavior
|
|
24
|
+
* there is unchanged.
|
|
25
|
+
* 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
|
|
26
|
+
* `<vendor>/`, excluding the requested id itself and excluding any
|
|
27
|
+
* `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
|
|
28
|
+
* only when vendor === 'openrouter' and are filtered out defensively).
|
|
29
|
+
*
|
|
30
|
+
* @param {{vendor?: string, model?: string}} descriptor parsed Descriptor for
|
|
31
|
+
* the request that produced the selection_required (canonical or
|
|
32
|
+
* openrouter-literal — both carry vendor/model)
|
|
33
|
+
* @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
|
|
34
|
+
* @param {{models: Array<{id:string}>}} catalogInfo
|
|
35
|
+
* @param {{direct?: string, openrouter?: string}} [gatewayIds] the same
|
|
36
|
+
* per-gateway id map resolveRouteForLaunch threads through resolveRoute
|
|
37
|
+
* (Task 3's bridge for divergent curated aliases); absent for non-alias /
|
|
38
|
+
* full-id / non-divergent requests
|
|
39
|
+
* @returns {Array<{model:string, gateway:string, note:string}>}
|
|
40
|
+
*/
|
|
41
|
+
function buildSuggestions(descriptor, keys, catalogInfo, gatewayIds) {
|
|
42
|
+
const suggestions = [];
|
|
43
|
+
const vendor = descriptor && descriptor.vendor;
|
|
44
|
+
const model = descriptor && descriptor.model;
|
|
45
|
+
if (!vendor || !model) { return suggestions; }
|
|
46
|
+
|
|
47
|
+
const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
|
|
48
|
+
const requestedDirectId = `${vendor}/${model}`;
|
|
49
|
+
|
|
50
|
+
if (keys && keys.openrouter) {
|
|
51
|
+
const orId = (gatewayIds && gatewayIds.openrouter) || `openrouter/${vendor}/${model}`;
|
|
52
|
+
if (models.some(m => m && m.id === orId)) {
|
|
53
|
+
suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const nsPrefix = `${vendor}/`;
|
|
58
|
+
const sameVendor = models.filter(m =>
|
|
59
|
+
m && typeof m.id === 'string' &&
|
|
60
|
+
m.id.startsWith(nsPrefix) &&
|
|
61
|
+
!m.id.startsWith('openrouter/') &&
|
|
62
|
+
m.id !== requestedDirectId
|
|
63
|
+
).slice(0, 5);
|
|
64
|
+
for (const m of sameVendor) {
|
|
65
|
+
suggestions.push({ model: m.id, gateway: 'direct', note: `${vendor} model` });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return suggestions.slice(0, 6);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* v4.2 (D10/M24): apply catalog-derived suggestions to a `selection_required`
|
|
73
|
+
* result — EXCEPT for a local vendor, whose suggestions the router already
|
|
74
|
+
* built from the live probe (spec §4.2 point 4: "suggestions = the live list,
|
|
75
|
+
* capped at 6"). buildSuggestions reads only `catalogInfo.models` (the 24 h
|
|
76
|
+
* cache, never `req.localLive`) and hardcodes `gateway:'direct'`, so letting it
|
|
77
|
+
* overwrite a local result ships stale-or-empty rows under the wrong label.
|
|
78
|
+
* @param {Object} result the `selection_required` RouteResult, mutated in place
|
|
79
|
+
*/
|
|
80
|
+
function applySuggestions(result, { descriptor, keys, catalogInfo, gatewayIds, localProviders }) {
|
|
81
|
+
if (localProviders && descriptor && Object.prototype.hasOwnProperty.call(localProviders, descriptor.vendor)) { return; }
|
|
82
|
+
result.suggestions = buildSuggestions(descriptor, keys, catalogInfo, gatewayIds);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { buildSuggestions, applySuggestions };
|