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.
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Route-launch views (#61 gateway routing integration, Task 4.2).
3
3
  *
4
- * Additive, read-only helpers consumed by Task 4.4's resolveRouteForLaunch
5
- * (not wired into any launch path yet). Pure-ish: all I/O goes through the
6
- * stubbable api-key-store / auth-json / model-catalog modules.
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 assembled.
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 (`openrouter/<vendor>/<model>`) is actually
60
- * present in the catalog (never suggest an id we can't confirm exists).
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
- * Bridge: alias -> descriptor -> resolveRoute (Task 4.4).
141
- * Resolves a raw model string to a Descriptor — if it is a known no-slash
142
- * alias (per getEffectiveAliases()), its concrete id is parsed instead, so an
143
- * alias pointing at an `openrouter/...` value is treated as an explicit,
144
- * force-OR literal while an alias pointing at a bare `vendor/model` is
145
- * policy-routed like any other canonical id. Assembles live key/catalog state
146
- * and delegates the actual decision to the pure gateway-router. Additive:
147
- * not wired into any launch path yet.
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 concrete = (typeof model === 'string' && !model.includes('/') && aliases[model]) ? aliases[model] : model;
159
- const descriptor = parseDescriptor(concrete, { aliases });
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: gateway-router's
162
- // catalogGate short-circuits to { ok:true } as soon as validateModel === false,
163
- // never consulting catalogInfo, so fetching it here would be wasted
164
- // latency/network (and can hit the network on a cold cache) for no benefit.
165
- // Strict === false (not just falsy) so this stays in lockstep with catalogGate's
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 result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo });
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
  };