amicus 2.2.0 → 3.1.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +13 -8
  4. package/bin/amicus.js +5 -0
  5. package/electron/close-guard.js +4 -4
  6. package/electron/fold.js +8 -8
  7. package/electron/ipc-guard.js +3 -3
  8. package/electron/main.js +31 -31
  9. package/electron/opencode-theme.js +3 -3
  10. package/electron/preload-content.js +1 -1
  11. package/electron/setup-ui.js +27 -3
  12. package/package.json +4 -3
  13. package/skills/second-opinion/SKILL.md +2 -0
  14. package/skills/sidecar/SKILL.md +66 -38
  15. package/src/cli-handlers-resume-continue.js +31 -4
  16. package/src/cli-handlers-run.js +15 -4
  17. package/src/cli.js +13 -0
  18. package/src/mcp-server.js +99 -12
  19. package/src/mcp-tools.js +26 -4
  20. package/src/opencode-client.js +18 -2
  21. package/src/sidecar/continue.js +10 -3
  22. package/src/sidecar/electron-install.js +9 -8
  23. package/src/sidecar/fanout-leg.js +26 -1
  24. package/src/sidecar/fanout-output.js +5 -0
  25. package/src/sidecar/fanout-validate.js +81 -0
  26. package/src/sidecar/fanout.js +65 -77
  27. package/src/sidecar/session-utils.js +6 -0
  28. package/src/sidecar/setup.js +2 -1
  29. package/src/utils/alias-resolver.js +6 -35
  30. package/src/utils/api-key-store.js +1 -9
  31. package/src/utils/auth-json.js +1 -1
  32. package/src/utils/config.js +98 -16
  33. package/src/utils/curated-models.js +33 -4
  34. package/src/utils/gateway-router.js +115 -0
  35. package/src/utils/input-validators.js +12 -42
  36. package/src/utils/model-classification.js +65 -0
  37. package/src/utils/model-descriptor.js +72 -0
  38. package/src/utils/model-fetcher.js +35 -9
  39. package/src/utils/model-input-default.js +32 -0
  40. package/src/utils/model-validator.js +68 -84
  41. package/src/utils/node-version-guard.js +16 -0
  42. package/src/utils/provider-registry.js +57 -0
  43. package/src/utils/quick-picks.js +11 -3
  44. package/src/utils/result-schema-rebuild.js +98 -0
  45. package/src/utils/result-schema.js +6 -76
  46. package/src/utils/route-error.js +137 -0
  47. package/src/utils/route-launch.js +179 -0
  48. package/src/utils/start-helpers.js +96 -43
  49. package/src/utils/validators.js +1 -8
@@ -8,7 +8,8 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
- const { applyDirectApiFallback, autoRepairAlias } = require('./alias-resolver');
11
+ const { autoRepairAlias } = require('./alias-resolver');
12
+ const { isDirectProvider } = require('./provider-registry');
12
13
 
13
14
  /** Default model alias map — derived from the curated-models single source (F5) */
14
15
  const { toDefaultAliases } = require('./curated-models');
@@ -111,7 +112,9 @@ function resolveModel(modelArg) {
111
112
  if (!resolved || resolved === 'null') {
112
113
  return autoRepairAlias(modelArg, config, DEFAULT_ALIASES, saveConfig);
113
114
  }
114
- return applyDirectApiFallback(resolved);
115
+ // Router (route-launch.js / gateway-router.js, #61) owns the
116
+ // direct-vs-OpenRouter decision now — return the stored id verbatim.
117
+ return resolved;
115
118
  }
116
119
 
117
120
  // Unknown alias
@@ -140,7 +143,8 @@ function resolveModel(modelArg) {
140
143
  if (!resolved || resolved === 'null') {
141
144
  return autoRepairAlias(defaultValue, config, DEFAULT_ALIASES, saveConfig);
142
145
  }
143
- return applyDirectApiFallback(resolved);
146
+ // Router owns the direct-vs-OpenRouter decision now — return verbatim.
147
+ return resolved;
144
148
  }
145
149
 
146
150
  // Default alias not found anywhere
@@ -235,16 +239,42 @@ function tryResolveModel(modelArg) {
235
239
  }
236
240
  }
237
241
 
238
- /** Build OpenCode provider.models config from sidecar aliases.
242
+ /** Build OpenCode provider.models config from sidecar aliases, plus the
243
+ * actually-resolved launch route(s). The alias-derived entries let the UI
244
+ * model picker show every configured model (single source of truth for the
245
+ * picker); resolvedRoutes ensures the id OpenCode is ACTUALLY told to launch
246
+ * (config.model) is always registered under its correct provider, even when
247
+ * an alias maps to a different provider for the same model (e.g. an alias
248
+ * stores `openrouter/openai/gpt-5.5` but the router resolves DIRECT to
249
+ * `openai/gpt-5.5` — without this, only `openrouter` would be registered,
250
+ * mismatching config.model).
251
+ *
252
+ * Catalog broadening (#61 whole-branch review, FIX 1): a resolvedRoutes entry
253
+ * only covers the route(s) resolved AT SERVER-CREATION TIME. A long-lived,
254
+ * multi-session server (the MCP shared server, `utils/shared-server.js`) is
255
+ * created ONCE via `sharedServer.ensureServer()` and then serves MANY
256
+ * sessions over its lifetime, each of which independently asks the gateway
257
+ * router (direct-first policy) to route the SAME bare alias — some sessions
258
+ * land DIRECT, others land on OpenRouter, depending on which keys happen to
259
+ * be configured when each session starts. Since the shared server's
260
+ * `provider.models` is fixed at creation and never rebuilt per-session,
261
+ * threading only that first session's resolved id is insufficient. So for
262
+ * every alias that resolves to a BARE direct-capable-vendor id (post-#61
263
+ * default aliases are bare, e.g. `openai/gpt-5.5`), this ALSO registers the
264
+ * `openrouter/<vendor>/<model>` form — broadening the catalog to cover BOTH
265
+ * routes the router might pick, regardless of which session created the
266
+ * server. This does NOT change what the alias itself resolves to (still
267
+ * bare, still direct-first) — it only widens what's pre-registered.
268
+ * @param {string[]} [resolvedRoutes] executable model id(s) actually launched
239
269
  * @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
240
- function buildProviderModels() {
270
+ function buildProviderModels(resolvedRoutes = []) {
241
271
  const aliases = getEffectiveAliases();
242
272
  const providers = {};
243
273
 
244
- for (const fullModel of Object.values(aliases)) {
245
- if (!fullModel || typeof fullModel !== 'string') { continue; }
274
+ const addRoute = (fullModel) => {
275
+ if (!fullModel || typeof fullModel !== 'string') { return; }
246
276
  const parts = fullModel.split('/');
247
- if (parts.length < 2) { continue; }
277
+ if (parts.length < 2) { return; }
248
278
 
249
279
  const providerID = parts[0];
250
280
  const modelID = parts.slice(1).join('/');
@@ -253,16 +283,28 @@ function buildProviderModels() {
253
283
  providers[providerID] = { models: {} };
254
284
  }
255
285
  providers[providerID].models[modelID] = {};
286
+ };
287
+
288
+ for (const fullModel of Object.values(aliases)) {
289
+ addRoute(fullModel);
290
+
291
+ // Broaden: a bare direct-capable-vendor route also gets an OpenRouter
292
+ // mirror registered (see catalog-broadening note above). Gateway-only
293
+ // aliases (already `openrouter/...`, e.g. grok/qwen/x-ai) are untouched —
294
+ // OpenRouter is their only possible route anyway, already covered above.
295
+ if (typeof fullModel === 'string' && !fullModel.startsWith('openrouter/')) {
296
+ const vendor = fullModel.split('/')[0];
297
+ if (isDirectProvider(vendor)) {
298
+ addRoute(`openrouter/${fullModel}`);
299
+ }
300
+ }
256
301
  }
257
302
 
258
- return providers;
259
- }
303
+ for (const resolved of resolvedRoutes) {
304
+ addRoute(resolved);
305
+ }
260
306
 
261
- /** Detect if direct API fallback was applied during alias resolution */
262
- function detectFallback(alias, resolvedModel) {
263
- if (!alias || alias.includes('/')) { return false; }
264
- const val = getEffectiveAliases()[alias];
265
- return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
307
+ return providers;
266
308
  }
267
309
 
268
310
  /** @returns {Object<string,string[]>} the councils map (empty if none) */
@@ -339,6 +381,44 @@ function resolveCouncilMembers(name, catalog = []) {
339
381
  return { models, dropped };
340
382
  }
341
383
 
384
+ /** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
385
+ function getRoutingConfig() {
386
+ const config = loadConfig() || {};
387
+ const r = (config.routing && typeof config.routing === 'object') ? config.routing : {};
388
+ const prefer = r.prefer === 'openrouter' ? 'openrouter' : 'direct';
389
+ const migration_notified = (r.migration_notified && typeof r.migration_notified === 'object') ? r.migration_notified : {};
390
+ return { prefer, migration_notified };
391
+ }
392
+
393
+ /** Merge --gateway (perCall) with routing.prefer into a router gatewayMode.
394
+ * @param {string|undefined} perCall 'auto'|'direct'|'openrouter'|undefined
395
+ * @returns {'auto'|'direct'|'openrouter'} */
396
+ function resolveGatewayMode(perCall) {
397
+ if (perCall && perCall !== 'auto') { return perCall; }
398
+ const { prefer } = getRoutingConfig();
399
+ return prefer === 'openrouter' ? 'openrouter' : 'auto';
400
+ }
401
+
402
+ /**
403
+ * Persist the one-time direct-migration notice flag for a vendor (#61 Task
404
+ * 5.1 — visible-migration guarantee). Best-effort: swallows any saveConfig
405
+ * failure so a persistence hiccup never breaks the launch that triggered it.
406
+ * @param {string} vendor
407
+ */
408
+ function markMigrationNotified(vendor) {
409
+ try {
410
+ const config = loadConfig() || {};
411
+ if (!config.routing || typeof config.routing !== 'object') { config.routing = {}; }
412
+ if (!config.routing.migration_notified || typeof config.routing.migration_notified !== 'object') {
413
+ config.routing.migration_notified = {};
414
+ }
415
+ config.routing.migration_notified[vendor] = true;
416
+ saveConfig(config);
417
+ } catch (_err) {
418
+ // best-effort: never fail the launch over a persistence error
419
+ }
420
+ }
421
+
342
422
  module.exports = {
343
423
  getConfigDir,
344
424
  getConfigPath,
@@ -346,7 +426,6 @@ module.exports = {
346
426
  saveConfig,
347
427
  getDefaultAliases,
348
428
  resolveModel,
349
- detectFallback,
350
429
  computeConfigHash,
351
430
  buildAliasTable,
352
431
  checkConfigChanged,
@@ -358,4 +437,7 @@ module.exports = {
358
437
  getCouncil,
359
438
  getCouncilWithSource,
360
439
  resolveCouncilMembers,
440
+ getRoutingConfig,
441
+ resolveGatewayMode,
442
+ markMigrationNotified,
361
443
  };
@@ -12,6 +12,8 @@
12
12
 
13
13
  'use strict';
14
14
 
15
+ const { isDirectProvider } = require('./provider-registry');
16
+
15
17
  /**
16
18
  * Wizard quick-pick families. `idPattern` matches the model segment after
17
19
  * `<vendorPath>/` (openrouter ns) or `<provider>/` (direct ns).
@@ -91,15 +93,42 @@ function getFamilies() {
91
93
  }
92
94
 
93
95
  /**
94
- * @returns {Object<string,string>} alias pinned route (openrouter first). STATIC — runtime-safe.
96
+ * Direct-first canonicalization for a pinned `openrouter/<vendor>/<rest>`
97
+ * route: when `<vendor>` has a direct integration (provider-registry
98
+ * `isDirectProvider`), strip the `openrouter/` prefix so the resulting bare
99
+ * `<vendor>/<rest>` id is policy-routed by the gateway router (direct when a
100
+ * direct key exists, OpenRouter otherwise). Gateway-only vendors (no direct
101
+ * integration — e.g. qwen, x-ai, z-ai, mistralai, minimax, moonshotai,
102
+ * bytedance-seed) are returned unchanged, since OpenRouter is their only
103
+ * route anyway. Non-openrouter routes (already bare, or malformed) pass
104
+ * through unchanged.
105
+ * @param {string} route
106
+ * @returns {string}
107
+ */
108
+ function toCanonicalDefault(route) {
109
+ if (typeof route === 'string' && route.startsWith('openrouter/')) {
110
+ const rest = route.slice('openrouter/'.length); // '<vendor>/<rest...>'
111
+ const slashIdx = rest.indexOf('/');
112
+ const vendor = slashIdx > 0 ? rest.slice(0, slashIdx) : null;
113
+ if (vendor && isDirectProvider(vendor)) { return rest; }
114
+ }
115
+ return route;
116
+ }
117
+
118
+ /**
119
+ * @returns {Object<string,string>} alias → pinned route, direct-first for
120
+ * direct-capable vendors (bare `vendor/model`), openrouter-prefixed for
121
+ * gateway-only vendors. STATIC — runtime-safe.
95
122
  */
96
123
  function toDefaultAliases() {
97
124
  const out = {};
98
125
  for (const f of FAMILIES) {
99
- out[f.alias] = f.fallback.openrouter || Object.values(f.fallback)[0];
126
+ const route = f.fallback.openrouter || Object.values(f.fallback)[0];
127
+ out[f.alias] = toCanonicalDefault(route);
100
128
  }
101
129
  for (const e of CARDLESS) {
102
- out[e.alias] = e.routes.openrouter || Object.values(e.routes)[0];
130
+ const route = e.routes.openrouter || Object.values(e.routes)[0];
131
+ out[e.alias] = toCanonicalDefault(route);
103
132
  }
104
133
  return out;
105
134
  }
@@ -122,4 +151,4 @@ function listCuratedRoutes() {
122
151
  return out;
123
152
  }
124
153
 
125
- module.exports = { getFamilies, toDefaultAliases, listCuratedRoutes };
154
+ module.exports = { getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure gateway router (#61). Decides direct vs OpenRouter for a request using
3
+ * only injected state (keys, catalogInfo, gatewayMode) — no I/O. Returns a
4
+ * RouteResult (resolved | selection_required | error). Wiring into launch paths
5
+ * is Plan 2; this module is behavior-neutral until then.
6
+ */
7
+ 'use strict';
8
+
9
+ const { resolved, routeError, selectionRequired, parseDescriptor } = require('./model-descriptor');
10
+ const { classifyModel } = require('./model-classification');
11
+ const { isDirectProvider } = require('./provider-registry');
12
+
13
+ /** Build the executable id for a gateway. */
14
+ function executableFor(gateway, vendor, model) {
15
+ return gateway === 'openrouter' ? `openrouter/${vendor}/${model}` : `${vendor}/${model}`;
16
+ }
17
+
18
+ /**
19
+ * Catalog gate: returns { ok:true, notice? } to proceed, or { ok:false, result }
20
+ * carrying a selection_required/error to return to the caller.
21
+ */
22
+ function catalogGate({ id, gateway, req }) {
23
+ if (req.validateModel === false) {
24
+ return { ok: true, notice: 'Model availability not validated (--no-validate-model).' };
25
+ }
26
+ const verdict = classifyModel(id, gateway, req.catalogInfo);
27
+ if (verdict === 'valid') { return { ok: true }; }
28
+ if (verdict === 'unknown') {
29
+ return { ok: true, notice: `Model '${id}' is unverified against the ${gateway} catalog; attempting anyway.` };
30
+ }
31
+ // invalid
32
+ if (req.allowSelection) {
33
+ return { ok: false, result: selectionRequired({ requested: req.descriptor.raw, suggestions: [] }) };
34
+ }
35
+ return { ok: false, result: routeError({ requested: req.descriptor.raw, reason: 'model_not_found',
36
+ preferredGateway: gateway, suggestions: [] }) };
37
+ }
38
+
39
+ /** Resolve to a concrete gateway after the catalog gate passes. */
40
+ function finish(gateway, vendor, model, req) {
41
+ const id = executableFor(gateway, vendor, model);
42
+ const gate = catalogGate({ id, gateway, req });
43
+ if (!gate.ok) { return gate.result; }
44
+ return resolved({ model: id, gateway, executableId: id,
45
+ provenance: { source: req.source, requested: req.descriptor.raw, gatewayMode: req.gatewayMode }, notice: gate.notice });
46
+ }
47
+
48
+ /**
49
+ * @param {object} req see Task 5 Interfaces
50
+ * @returns RouteResult
51
+ */
52
+ function resolveRoute(req) {
53
+ // 1. Normalize: req.descriptor may arrive as a parsed Descriptor object or a
54
+ // raw canonical/OR-literal id string. Only canonical/openrouter-literal kinds
55
+ // are routable; alias/invalid/garbage descriptors are rejected right here,
56
+ // before any branch below touches vendor/model. All downstream code reads
57
+ // from the normalized `d` (via the cloned `rq`), never from req.descriptor.
58
+ let d = req.descriptor;
59
+ if (typeof d === 'string') {
60
+ d = parseDescriptor(d, { aliases: {} });
61
+ }
62
+ if (!d || (d.kind !== 'canonical' && d.kind !== 'openrouter-literal')) {
63
+ const requested = d ? d.raw : (typeof req.descriptor === 'string' ? req.descriptor : String(req && req.descriptor));
64
+ return routeError({ requested, reason: 'invalid_descriptor', preferredGateway: req.gatewayMode, suggestions: [] });
65
+ }
66
+ const rq = { ...req, descriptor: d };
67
+ const vendor = d.vendor;
68
+ const model = d.model;
69
+
70
+ // 2. Explicit conflict: force-OR literal vs --gateway direct
71
+ if (d.isExplicitOpenRouter && rq.gatewayMode === 'direct') {
72
+ return routeError({ requested: d.raw, reason: 'gateway_conflict', preferredGateway: 'direct', suggestions: [] });
73
+ }
74
+ // 3. Explicit OR literal
75
+ if (d.isExplicitOpenRouter) {
76
+ if (!rq.keys.openrouter) {
77
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
78
+ }
79
+ return finish('openrouter', vendor, model, rq);
80
+ }
81
+ // 4. Gateway-only vendor (no direct integration)
82
+ if (!isDirectProvider(vendor)) {
83
+ if (rq.gatewayMode === 'direct') {
84
+ return routeError({ requested: d.raw, reason: 'no_direct_integration', preferredGateway: 'direct', suggestions: [] });
85
+ }
86
+ if (!rq.keys.openrouter) {
87
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
88
+ }
89
+ return finish('openrouter', vendor, model, rq);
90
+ }
91
+ // 5. Explicit --gateway openrouter
92
+ if (rq.gatewayMode === 'openrouter') {
93
+ if (!rq.keys.openrouter) {
94
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
95
+ }
96
+ return finish('openrouter', vendor, model, rq);
97
+ }
98
+ // 6. Explicit --gateway direct
99
+ if (rq.gatewayMode === 'direct') {
100
+ if (!rq.keys[vendor]) {
101
+ return routeError({ requested: d.raw, reason: 'no_direct_key', preferredGateway: 'direct', suggestions: [] });
102
+ }
103
+ return finish('direct', vendor, model, rq);
104
+ }
105
+ // 7. auto (direct-first)
106
+ if (rq.keys[vendor]) {
107
+ return finish('direct', vendor, model, rq);
108
+ }
109
+ if (rq.keys.openrouter) {
110
+ return finish('openrouter', vendor, model, rq);
111
+ }
112
+ return routeError({ requested: d.raw, reason: 'no_key_for_vendor', preferredGateway: 'direct', suggestions: [] });
113
+ }
114
+
115
+ module.exports = { resolveRoute };
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * @module input-validators
5
5
  * MCP input validation with structured error responses.
6
- * Composes validators from validators.js and adds model resolution.
6
+ * Composes validators from validators.js for prompt/timeout/agent checks.
7
+ * Model resolution/routing lives in mcp-server.js (#61 Task 6.2), not here.
7
8
  */
8
9
 
9
10
  // Lazy require to avoid circular dependency (validators.js re-exports from here)
@@ -13,26 +14,15 @@ function getValidators() {
13
14
  return _validators;
14
15
  }
15
16
 
16
- /**
17
- * Find candidates that start with the input or vice versa.
18
- * @param {string} input
19
- * @param {string[]} candidates
20
- * @returns {string[]} Up to 3 matching candidates
21
- */
22
- function findSimilar(input, candidates) {
23
- if (!input) { return []; }
24
- const lower = input.toLowerCase();
25
- return candidates.filter(c => {
26
- const cl = c.toLowerCase();
27
- return cl.startsWith(lower) || lower.startsWith(cl);
28
- }).slice(0, 3);
29
- }
30
-
31
17
  /**
32
18
  * Validate sidecar_start inputs before session creation.
33
- * Composes existing validators and adds model resolution.
19
+ * Composes existing validators for prompt/timeout/agent. Model resolution is
20
+ * NOT this function's concern (#61 Task 6.2): it is routed separately by the
21
+ * mcp-server.js amicus_start handler via resolveRouteForLaunch, so a routing
22
+ * failure can be rendered as a structured `model_route_error` (parity with the
23
+ * CLI's resolveLaunchModel) rather than the `validation_error` shape below.
34
24
  * @param {Object} input - Raw MCP tool input
35
- * @returns {{ valid: true, resolvedModel: string } | { valid: false, error: Object }}
25
+ * @returns {{ valid: true } | { valid: false, error: Object }}
36
26
  */
37
27
  function validateStartInputs(input) {
38
28
  // 1. Prompt
@@ -49,27 +39,7 @@ function validateStartInputs(input) {
49
39
  };
50
40
  }
51
41
 
52
- // 2. Model: resolve alias to full provider/model string
53
- const { tryResolveModel, getEffectiveAliases } = require('./config');
54
- const { model: resolved, error: modelError } = tryResolveModel(input.model);
55
- if (modelError) {
56
- const aliases = Object.keys(getEffectiveAliases());
57
- const suggestions = findSimilar(input.model, aliases);
58
- return {
59
- valid: false,
60
- error: {
61
- type: 'validation_error',
62
- field: 'model',
63
- message: input.model
64
- ? `Model '${input.model}' not found. ${modelError}`
65
- : `No model specified and no default configured. ${modelError}`,
66
- suggestions,
67
- available: aliases,
68
- },
69
- };
70
- }
71
-
72
- // 3. Timeout: positive number, max 60 minutes
42
+ // 2. Timeout: positive number, max 60 minutes
73
43
  if (input.timeout !== undefined) {
74
44
  const t = Number(input.timeout);
75
45
  if (isNaN(t) || t <= 0) {
@@ -94,7 +64,7 @@ function validateStartInputs(input) {
94
64
  }
95
65
  }
96
66
 
97
- // 4. Agent + headless compatibility
67
+ // 3. Agent + headless compatibility
98
68
  // Note: MCP Zod schema defaults agent to 'Chat'. The handler auto-converts
99
69
  // Chat to Build for headless mode (line ~92 in mcp-server.js). Only reject
100
70
  // if the user explicitly set agent to Chat with noUi (not the Zod default).
@@ -121,7 +91,7 @@ function validateStartInputs(input) {
121
91
  }
122
92
  }
123
93
 
124
- return { valid: true, resolvedModel: resolved };
94
+ return { valid: true };
125
95
  }
126
96
 
127
97
  /**
@@ -175,4 +145,4 @@ function suggestCommand(input, candidates, maxDistance = 2, maxSuggestions = 3)
175
145
  .map(({ c }) => c);
176
146
  }
177
147
 
178
- module.exports = { validateStartInputs, findSimilar, levenshteinDistance, suggestCommand };
148
+ module.exports = { validateStartInputs, levenshteinDistance, suggestCommand };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tri-state catalog classification (#61).
3
+ * Turns the combined catalog + refresh outcome into valid|invalid|unknown for a
4
+ * (model, gateway) pair. `unknown` NEVER rejects a route — it preserves the
5
+ * existing "empty catalog cannot validate, never block a launch" contract.
6
+ *
7
+ * Namespace matching is scoped PER-VENDOR, not by a flat openrouter/direct
8
+ * split. The combined catalog always contains a hardcoded Anthropic floor, so
9
+ * a flat "direct" namespace is never empty even when a specific vendor's rows
10
+ * were never fetched (e.g. that provider's /models call was stale or failed).
11
+ * Matching per-vendor means a missing vendor's rows correctly yield `unknown`
12
+ * instead of being masked by the always-present floor rows of another vendor.
13
+ *
14
+ * - gateway === 'direct': vendor = id.split('/')[0]; namespace prefix is
15
+ * `${vendor}/`, restricted to rows NOT under `openrouter/`.
16
+ * - gateway === 'openrouter': id looks like `openrouter/<vendor>/<model>`, so
17
+ * vendor = id.split('/')[1]; namespace prefix is `openrouter/${vendor}/`.
18
+ *
19
+ * Non-authoritative rows (`authoritative: false`, e.g. the hardcoded Anthropic
20
+ * floor-fallback tagged by model-fetcher.js when a keyed live fetch fails or no
21
+ * key is present — see fetchModelsFromProvider('anthropic', key)) cannot assert
22
+ * absence either: if EVERY row in the matched namespace is non-authoritative and
23
+ * the exact id is not among them, a miss returns `unknown`, not `invalid` — the
24
+ * floor is a stale/synthesized list, not a confirmed model roster, so it must
25
+ * never hard-block a launch (#61 4.3). A namespace containing at least one
26
+ * authoritative (live-fetched) row still yields `invalid` on a genuine miss.
27
+ *
28
+ * Pure: the caller passes catalogInfo (from model-catalog.getCatalogInfo()).
29
+ * @param {string} id exact model id as the user gave it
30
+ * @param {'direct'|'openrouter'} gateway which namespace to match against
31
+ * @param {{models: Array<{id:string, authoritative?: boolean}>, lastRefreshError?: string|null}} catalogInfo
32
+ * @returns {'valid'|'invalid'|'unknown'}
33
+ */
34
+ function classifyModel(id, gateway, catalogInfo) {
35
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
36
+ if (models.length === 0) { return 'unknown'; }
37
+
38
+ const idParts = typeof id === 'string' ? id.split('/') : [];
39
+ const isOpenRouter = gateway === 'openrouter';
40
+ const vendor = isOpenRouter ? idParts[1] : idParts[0];
41
+ const nsPrefix = isOpenRouter ? `openrouter/${vendor}/` : `${vendor}/`;
42
+
43
+ const inNamespace = (mid) => {
44
+ if (typeof mid !== 'string' || !mid.startsWith(nsPrefix)) { return false; }
45
+ return isOpenRouter ? true : !mid.startsWith('openrouter/');
46
+ };
47
+ const namespaceRows = models.filter(m => m && typeof m.id === 'string' && inNamespace(m.id));
48
+
49
+ // No rows for this vendor's namespace -> we cannot assert absence (e.g. that
50
+ // provider's key is absent so its rows were never fetched, or a partial
51
+ // refresh). Unknown — never block on a namespace we couldn't populate.
52
+ if (namespaceRows.length === 0) { return 'unknown'; }
53
+
54
+ const present = namespaceRows.some(m => m.id === id);
55
+ if (present) { return 'valid'; }
56
+
57
+ // Every matched row is a non-authoritative floor-fallback row (never live-
58
+ // fetched) -> a miss cannot be trusted as a confirmed absence. Never block.
59
+ const allNonAuthoritative = namespaceRows.every(m => m.authoritative === false);
60
+ if (allNonAuthoritative) { return 'unknown'; }
61
+
62
+ return 'invalid';
63
+ }
64
+
65
+ module.exports = { classifyModel };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Model-descriptor grammar + RouteResult factories (#61).
3
+ * Pure string classification — no I/O, no provider lookups. The resolver
4
+ * (gateway-router.js) consumes Descriptors and returns RouteResults.
5
+ */
6
+ 'use strict';
7
+
8
+ const GATEWAY_MODES = ['auto', 'direct', 'openrouter'];
9
+ const OR_PREFIX = 'openrouter/';
10
+
11
+ /**
12
+ * Classify a raw model string into a normalized descriptor.
13
+ * Grammar:
14
+ * - `openrouter/<vendor>/<model>` -> openrouter-literal (explicit force-OR)
15
+ * - `<vendor>/<model...>` -> canonical (policy-routed)
16
+ * - known no-slash alias -> alias (resolution deferred to caller)
17
+ * - anything else -> invalid (incl. unknown no-slash token)
18
+ * @param {string} raw
19
+ * @param {{aliases: Object<string,string>}} ctx
20
+ * @returns {{raw:string, kind:string, vendor?:string, model?:string, isExplicitOpenRouter:boolean, error?:string}}
21
+ */
22
+ function parseDescriptor(raw, ctx = {}) {
23
+ const aliases = ctx.aliases || {};
24
+ const trimmed = typeof raw === 'string' ? raw.trim() : '';
25
+ if (!trimmed) {
26
+ return { raw, kind: 'invalid', isExplicitOpenRouter: false, error: 'Empty model identifier' };
27
+ }
28
+ if (trimmed.startsWith(OR_PREFIX)) {
29
+ const rest = trimmed.slice(OR_PREFIX.length);
30
+ const parts = rest.split('/');
31
+ if (parts.length < 2 || !parts[0] || !parts.slice(1).join('/')) {
32
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: true,
33
+ error: `Malformed OpenRouter model id '${trimmed}' (expected openrouter/vendor/model)` };
34
+ }
35
+ return { raw: trimmed, kind: 'openrouter-literal', vendor: parts[0],
36
+ model: parts.slice(1).join('/'), isExplicitOpenRouter: true };
37
+ }
38
+ if (trimmed.includes('/')) {
39
+ const parts = trimmed.split('/');
40
+ if (parts.length < 2 || !parts[0] || !parts.slice(1).join('/')) {
41
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: false,
42
+ error: `Malformed model id '${trimmed}' (expected vendor/model)` };
43
+ }
44
+ return { raw: trimmed, kind: 'canonical', vendor: parts[0],
45
+ model: parts.slice(1).join('/'), isExplicitOpenRouter: false };
46
+ }
47
+ if (Object.prototype.hasOwnProperty.call(aliases, trimmed)) {
48
+ return { raw: trimmed, kind: 'alias', isExplicitOpenRouter: false };
49
+ }
50
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: false,
51
+ error: `Unknown model alias '${trimmed}'. Run 'amicus setup' to configure aliases, or use a vendor/model id.` };
52
+ }
53
+
54
+ /** @returns {{kind:'resolved', model:string, gateway:string, executableId:string, provenance:object, notice?:string}} */
55
+ function resolved({ model, gateway, executableId, provenance, notice }) {
56
+ const out = { kind: 'resolved', model, gateway, executableId, provenance: provenance || {} };
57
+ if (notice) { out.notice = notice; }
58
+ return out;
59
+ }
60
+
61
+ /** @returns {{kind:'selection_required', requested:string, suggestions:Array}} */
62
+ function selectionRequired({ requested, suggestions }) {
63
+ return { kind: 'selection_required', requested, suggestions: suggestions || [] };
64
+ }
65
+
66
+ /** @returns {{kind:'error', type:'model_route_error', ...}} */
67
+ function routeError({ field, requested, reason, preferredGateway, suggestions }) {
68
+ return { kind: 'error', type: 'model_route_error', field: field || 'model',
69
+ requested, reason, preferredGateway, suggestions: suggestions || [] };
70
+ }
71
+
72
+ module.exports = { GATEWAY_MODES, parseDescriptor, resolved, selectionRequired, routeError };
@@ -16,13 +16,7 @@ const ANTHROPIC_MODELS = [
16
16
  { id: 'anthropic/claude-3-5-haiku', name: 'Claude 3.5 Haiku', contextLength: null, pricing: null }
17
17
  ];
18
18
 
19
- const PROVIDER_FAMILY_NAMES = {
20
- openrouter: 'OpenRouter',
21
- google: 'Google',
22
- openai: 'OpenAI',
23
- anthropic: 'Anthropic',
24
- deepseek: 'DeepSeek'
25
- };
19
+ const { PROVIDER_FAMILY_NAMES } = require('./provider-registry');
26
20
 
27
21
  /** Provider API configs for fetching model lists */
28
22
  const PROVIDER_FETCH_CONFIG = {
@@ -82,7 +76,20 @@ const PROVIDER_FETCH_CONFIG = {
82
76
  pricing: null
83
77
  }));
84
78
  }
85
- }
79
+ },
80
+ anthropic: {
81
+ url: 'https://api.anthropic.com/v1/models',
82
+ authHeader: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
83
+ normalize: (body) => {
84
+ const data = JSON.parse(body);
85
+ return (data.data || []).map(m => ({
86
+ id: `anthropic/${m.id}`,
87
+ name: m.display_name || m.id,
88
+ contextLength: null,
89
+ pricing: null,
90
+ }));
91
+ },
92
+ },
86
93
  };
87
94
 
88
95
  const FETCH_TIMEOUT_MS = 5000;
@@ -95,7 +102,14 @@ const FETCH_TIMEOUT_MS = 5000;
95
102
  */
96
103
  function fetchModelsFromProvider(provider, key) {
97
104
  if (provider === 'anthropic') {
98
- return Promise.resolve(ANTHROPIC_MODELS);
105
+ // No key -> hardcoded floor, no network. With a key -> try live, fall back to floor.
106
+ // Floor-fallback rows are tagged authoritative:false (#61 4.3) so classifyModel
107
+ // never hard-blocks a miss against a stale/hardcoded list -- it returns
108
+ // 'unknown' instead. Rows from a successful live fetch are NOT tagged (they
109
+ // are authoritative). Map to new objects; never mutate ANTHROPIC_MODELS in place.
110
+ if (!key) { return Promise.resolve(ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))); }
111
+ return fetchViaConfig('anthropic', key).then(rows =>
112
+ (rows.length > 0 ? rows : ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))));
99
113
  }
100
114
 
101
115
  const config = PROVIDER_FETCH_CONFIG[provider];
@@ -103,6 +117,18 @@ function fetchModelsFromProvider(provider, key) {
103
117
  return Promise.resolve([]);
104
118
  }
105
119
 
120
+ return fetchViaConfig(provider, key);
121
+ }
122
+
123
+ /**
124
+ * Perform the HTTPS fetch + normalize for a single configured provider.
125
+ * Resolves to `[]` on any non-200 response, network error, timeout, or parse error.
126
+ * @param {string} provider - Key into PROVIDER_FETCH_CONFIG
127
+ * @param {string} key - API key
128
+ * @returns {Promise<Array>} Normalized model rows, or [] on any failure
129
+ */
130
+ function fetchViaConfig(provider, key) {
131
+ const config = PROVIDER_FETCH_CONFIG[provider];
106
132
  const url = config.buildUrl ? config.buildUrl(key) : config.url;
107
133
  const headers = config.authHeader(key);
108
134