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.
Files changed (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/README.md +20 -1
  4. package/bin/amicus.js +10 -0
  5. package/electron/ipc-setup-local.js +109 -0
  6. package/electron/ipc-setup.js +14 -2
  7. package/electron/preload-setup.js +3 -1
  8. package/electron/setup-ui-local-script.js +114 -0
  9. package/electron/setup-ui-local.js +75 -0
  10. package/electron/setup-ui-styles.js +15 -1
  11. package/electron/setup-ui.js +6 -1
  12. package/package.json +1 -1
  13. package/scripts/postinstall.js +12 -211
  14. package/src/cli-handlers-doctor.js +12 -0
  15. package/src/cli-handlers-init.js +83 -0
  16. package/src/cli-handlers-key-local.js +144 -0
  17. package/src/cli-handlers-provider.js +212 -0
  18. package/src/cli-handlers.js +31 -3
  19. package/src/cli.js +21 -0
  20. package/src/sidecar/setup-local.js +75 -0
  21. package/src/sidecar/setup.js +101 -6
  22. package/src/utils/api-key-store.js +12 -62
  23. package/src/utils/claude-register.js +267 -0
  24. package/src/utils/config.js +82 -10
  25. package/src/utils/doctor-local-providers-check.js +62 -0
  26. package/src/utils/doctor-summary.js +33 -0
  27. package/src/utils/env-loader.js +13 -0
  28. package/src/utils/env-raw-store.js +111 -0
  29. package/src/utils/gateway-router.js +65 -2
  30. package/src/utils/lifecycle.js +2 -1
  31. package/src/utils/local-probe.js +109 -0
  32. package/src/utils/local-providers.js +141 -0
  33. package/src/utils/model-catalog.js +6 -2
  34. package/src/utils/model-fetcher.js +11 -1
  35. package/src/utils/pricing.js +23 -9
  36. package/src/utils/provider-default-picker.js +17 -2
  37. package/src/utils/provider-default-prompt.js +7 -4
  38. package/src/utils/quick-picks.js +35 -12
  39. package/src/utils/route-error.js +10 -3
  40. package/src/utils/route-launch.js +8 -65
  41. package/src/utils/route-suggestions.js +85 -0
@@ -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). The overlaid route is run through
71
- * `toCanonicalDefault` so a direct-capable vendor (e.g. google, openai)
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
- seeds[r.alias] = toCanonicalDefault(r.routes.openrouter);
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 };
@@ -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
- const KEY_REASONS = ['no_openrouter_key', 'no_direct_key', 'no_key_for_vendor'];
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
- let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo, gatewayIds });
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.suggestions = buildSuggestions(descriptor, keys, catalogInfo, gatewayIds);
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 };