amicus 3.0.0 → 3.1.1

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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +66 -0
  3. package/README.md +13 -4
  4. package/electron/setup-ui-aliases.js +1 -1
  5. package/electron/setup-ui.js +27 -3
  6. package/package.json +2 -1
  7. package/skills/second-opinion/SKILL.md +2 -0
  8. package/skills/sidecar/SKILL.md +66 -38
  9. package/src/cli-handlers-resume-continue.js +31 -4
  10. package/src/cli-handlers-run.js +15 -4
  11. package/src/cli.js +17 -0
  12. package/src/mcp-server.js +99 -12
  13. package/src/mcp-tools.js +26 -4
  14. package/src/opencode-client.js +18 -2
  15. package/src/sidecar/continue.js +10 -3
  16. package/src/sidecar/fanout-leg.js +26 -1
  17. package/src/sidecar/fanout-output.js +5 -0
  18. package/src/sidecar/fanout-validate.js +81 -0
  19. package/src/sidecar/fanout.js +65 -77
  20. package/src/sidecar/models.js +39 -17
  21. package/src/sidecar/session-utils.js +6 -0
  22. package/src/sidecar/setup.js +2 -1
  23. package/src/utils/alias-resolver.js +6 -35
  24. package/src/utils/api-key-store.js +1 -9
  25. package/src/utils/auth-json.js +1 -1
  26. package/src/utils/config.js +98 -16
  27. package/src/utils/curated-models.js +99 -8
  28. package/src/utils/gateway-route-audit.js +103 -0
  29. package/src/utils/gateway-route-catalog.js +92 -0
  30. package/src/utils/gateway-router.js +131 -0
  31. package/src/utils/input-validators.js +12 -42
  32. package/src/utils/model-classification.js +65 -0
  33. package/src/utils/model-descriptor.js +72 -0
  34. package/src/utils/model-fetcher.js +46 -14
  35. package/src/utils/model-input-default.js +32 -0
  36. package/src/utils/model-validator.js +68 -84
  37. package/src/utils/provider-registry.js +57 -0
  38. package/src/utils/quick-picks.js +11 -3
  39. package/src/utils/result-schema-rebuild.js +98 -0
  40. package/src/utils/result-schema.js +15 -78
  41. package/src/utils/route-error.js +154 -0
  42. package/src/utils/route-launch.js +219 -0
  43. package/src/utils/start-helpers.js +96 -43
  44. package/src/utils/validators.js +1 -8
@@ -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).
@@ -45,7 +47,7 @@ const FAMILIES = [
45
47
  idPattern: /^claude-opus-[\d.-]+$/,
46
48
  directProviders: ['anthropic'],
47
49
  fallback: { openrouter: 'openrouter/anthropic/claude-opus-4.8',
48
- anthropic: 'anthropic/claude-opus-4-6' } },
50
+ anthropic: 'anthropic/claude-opus-4-8' } },
49
51
  { alias: 'deepseek', label: 'DeepSeek flagship', blurb: 'open-source',
50
52
  vendorPath: 'deepseek',
51
53
  idPattern: /^deepseek-v[\d.]+(-pro)?$/,
@@ -62,9 +64,13 @@ const CARDLESS = [
62
64
  { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.5-pro' } },
63
65
  // codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
64
66
  { alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
65
- { alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
66
- { alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
67
- { alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5' } },
67
+ { alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
68
+ anthropic: 'anthropic/claude-sonnet-5' } },
69
+ { alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
70
+ anthropic: 'anthropic/claude-sonnet-5' } },
71
+ { alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5',
72
+ anthropic: 'anthropic/claude-haiku-4-5-20251001' } },
73
+ { alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5' } },
68
74
  { alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
69
75
  { alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
70
76
  { alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
@@ -91,15 +97,42 @@ function getFamilies() {
91
97
  }
92
98
 
93
99
  /**
94
- * @returns {Object<string,string>} alias pinned route (openrouter first). STATIC — runtime-safe.
100
+ * Direct-first canonicalization for a pinned `openrouter/<vendor>/<rest>`
101
+ * route: when `<vendor>` has a direct integration (provider-registry
102
+ * `isDirectProvider`), strip the `openrouter/` prefix so the resulting bare
103
+ * `<vendor>/<rest>` id is policy-routed by the gateway router (direct when a
104
+ * direct key exists, OpenRouter otherwise). Gateway-only vendors (no direct
105
+ * integration — e.g. qwen, x-ai, z-ai, mistralai, minimax, moonshotai,
106
+ * bytedance-seed) are returned unchanged, since OpenRouter is their only
107
+ * route anyway. Non-openrouter routes (already bare, or malformed) pass
108
+ * through unchanged.
109
+ * @param {string} route
110
+ * @returns {string}
111
+ */
112
+ function toCanonicalDefault(route) {
113
+ if (typeof route === 'string' && route.startsWith('openrouter/')) {
114
+ const rest = route.slice('openrouter/'.length); // '<vendor>/<rest...>'
115
+ const slashIdx = rest.indexOf('/');
116
+ const vendor = slashIdx > 0 ? rest.slice(0, slashIdx) : null;
117
+ if (vendor && isDirectProvider(vendor)) { return rest; }
118
+ }
119
+ return route;
120
+ }
121
+
122
+ /**
123
+ * @returns {Object<string,string>} alias → pinned route, direct-first for
124
+ * direct-capable vendors (bare `vendor/model`), openrouter-prefixed for
125
+ * gateway-only vendors. STATIC — runtime-safe.
95
126
  */
96
127
  function toDefaultAliases() {
97
128
  const out = {};
98
129
  for (const f of FAMILIES) {
99
- out[f.alias] = f.fallback.openrouter || Object.values(f.fallback)[0];
130
+ const route = f.fallback.openrouter || Object.values(f.fallback)[0];
131
+ out[f.alias] = toCanonicalDefault(route);
100
132
  }
101
133
  for (const e of CARDLESS) {
102
- out[e.alias] = e.routes.openrouter || Object.values(e.routes)[0];
134
+ const route = e.routes.openrouter || Object.values(e.routes)[0];
135
+ out[e.alias] = toCanonicalDefault(route);
103
136
  }
104
137
  return out;
105
138
  }
@@ -122,4 +155,62 @@ function listCuratedRoutes() {
122
155
  return out;
123
156
  }
124
157
 
125
- module.exports = { getFamilies, toDefaultAliases, listCuratedRoutes };
158
+ /**
159
+ * Vendors whose direct-API ids differ from OpenRouter's (dot vs. dash
160
+ * versioning, distinct model names, etc.). NEVER derive a direct form for
161
+ * these — derivation would emit the wrong (dot) id, or invent a direct id
162
+ * for a model that is OpenRouter-only today (e.g. fable).
163
+ */
164
+ const DIVERGENT_VENDORS = new Set(['anthropic']);
165
+
166
+ /**
167
+ * @param {string} orRoute e.g. 'openrouter/anthropic/claude-sonnet-5'
168
+ * @returns {string} the vendor segment, e.g. 'anthropic'
169
+ */
170
+ function vendorOf(orRoute) {
171
+ const rest = orRoute.slice('openrouter/'.length);
172
+ return rest.slice(0, rest.indexOf('/'));
173
+ }
174
+
175
+ /**
176
+ * @param {string} vendorPath
177
+ * @param {Object<string,string>} obj a family.fallback or cardless.routes map
178
+ * @returns {string|undefined} the direct-API executable id, or undefined
179
+ * when no direct form is available for this alias.
180
+ */
181
+ function directFormFor(vendorPath, obj) {
182
+ if (obj[vendorPath]) { return obj[vendorPath]; } // explicit, authored, current direct id
183
+ if (DIVERGENT_VENDORS.has(vendorPath)) { return undefined; } // no explicit form + divergent → omit
184
+ const bare = toCanonicalDefault(obj.openrouter); // safe only when ids are identical across gateways
185
+ return bare !== obj.openrouter ? bare : undefined; // gateway-only vendor → undefined
186
+ }
187
+
188
+ /**
189
+ * @param {string} vendorPath
190
+ * @param {Object<string,string>} obj a family.fallback or cardless.routes map
191
+ * @returns {{direct?: string, openrouter: string}}
192
+ */
193
+ function gatewayRoutesFor(vendorPath, obj) {
194
+ const routes = { openrouter: obj.openrouter };
195
+ const direct = directFormFor(vendorPath, obj);
196
+ if (direct) { routes.direct = direct; }
197
+ return routes;
198
+ }
199
+
200
+ /**
201
+ * @returns {Object<string,{direct?: string, openrouter: string}>} alias →
202
+ * per-gateway executable ids. Unlike `toDefaultAliases` (a single pinned
203
+ * string per alias, used for display/`config.default`), this carries BOTH
204
+ * gateway-native forms so the router (Task 3) can route direct-first
205
+ * without corrupting divergent-vendor ids (e.g. Anthropic's dash format).
206
+ */
207
+ function toGatewayRoutes() {
208
+ const out = {};
209
+ for (const f of FAMILIES) { out[f.alias] = gatewayRoutesFor(f.vendorPath, f.fallback); }
210
+ for (const e of CARDLESS) { out[e.alias] = gatewayRoutesFor(vendorOf(e.routes.openrouter), e.routes); }
211
+ return out;
212
+ }
213
+
214
+ module.exports = {
215
+ getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes
216
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Per-gateway-form audit for curated DEFAULT aliases (Task 6, #gwid).
3
+ *
4
+ * Complements the flat alias-audit.js (which audits the single pinned string
5
+ * per alias from `toDefaultAliases()`/`listCuratedRoutes()`): this audits
6
+ * BOTH gateway-native forms from `curated-models.toGatewayRoutes()` against
7
+ * the live catalog.
8
+ *
9
+ * - STALE a stored form's id is absent from its namespace.
10
+ * - DIVERGENT a direct-capable vendor's alias is missing a `direct` form
11
+ * the catalog can now confirm ('divergent-missing'), or its
12
+ * stored `direct` form no longer matches what the catalog
13
+ * pairs ('divergent-mismatch').
14
+ *
15
+ * Never reports against data it cannot trust: a direct namespace the process
16
+ * has no key for is skipped, not flagged --
17
+ * - STALE relies on classifyModel(), whose 'unknown' already covers this
18
+ * (empty namespace, or every row a non-authoritative floor-fallback).
19
+ * - DIVERGENT additionally re-checks `authoritative` itself, because
20
+ * pairAcrossGateways() (Task 5) is a pure string matcher that has no
21
+ * concept of authoritative vs. floor-fallback rows -- left unguarded, it
22
+ * would happily "confirm" a direct pairing against the hardcoded
23
+ * Anthropic offline floor (e.g. matching a dated id like
24
+ * claude-haiku-4-5-20251001 to the floor's undated claude-haiku-4-5) and
25
+ * report a false mismatch with no key present at all.
26
+ *
27
+ * Consumed by `amicus models --check` (Task 6); `--strict` gates the exit
28
+ * code on these findings.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const { toGatewayRoutes } = require('./curated-models');
34
+ const { classifyModel } = require('./model-classification');
35
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
36
+ const { isDirectProvider } = require('./provider-registry');
37
+
38
+ const OR_PREFIX = 'openrouter/';
39
+
40
+ /** @param {string} orId e.g. 'openrouter/anthropic/claude-sonnet-5' @returns {string|null} vendor segment */
41
+ function vendorOf(orId) {
42
+ if (typeof orId !== 'string' || !orId.startsWith(OR_PREFIX)) { return null; }
43
+ const rest = orId.slice(OR_PREFIX.length);
44
+ const i = rest.indexOf('/');
45
+ return i > 0 ? rest.slice(0, i) : null;
46
+ }
47
+
48
+ /**
49
+ * Bare model segment for pairAcrossGateways' versionToken -- Task-5's locked
50
+ * calling convention: strip BOTH the `openrouter/` and `<vendor>/` prefixes
51
+ * before calling, never pass the route string verbatim.
52
+ * @param {string} orId @param {string} vendor @returns {string|null}
53
+ */
54
+ function bareSegment(orId, vendor) {
55
+ const prefix = `${OR_PREFIX}${vendor}/`;
56
+ return typeof orId === 'string' && orId.startsWith(prefix) ? orId.slice(prefix.length) : null;
57
+ }
58
+
59
+ /** @returns {boolean} true only when `id` names a live-fetched (not floor-fallback) catalog row */
60
+ function isAuthoritative(catalogInfo, id) {
61
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
62
+ const row = models.find(m => m && m.id === id);
63
+ return !!row && row.authoritative !== false;
64
+ }
65
+
66
+ /**
67
+ * @param {{models: Array<{id:string, authoritative?: boolean}>, lastRefreshError?: string|null}} catalogInfo
68
+ * @returns {Array<{alias:string, gateway:'direct'|'openrouter',
69
+ * kind:'stale'|'divergent-missing'|'divergent-mismatch', model:string, expected?:string}>}
70
+ */
71
+ function auditGatewayRoutes(catalogInfo) {
72
+ const routes = toGatewayRoutes();
73
+ const findings = [];
74
+
75
+ for (const [alias, forms] of Object.entries(routes)) {
76
+ for (const gateway of ['direct', 'openrouter']) {
77
+ const id = forms[gateway];
78
+ if (!id) { continue; }
79
+ if (classifyModel(id, gateway, catalogInfo) === 'invalid') {
80
+ findings.push({ alias, gateway, kind: 'stale', model: id });
81
+ }
82
+ }
83
+
84
+ const vendor = vendorOf(forms.openrouter);
85
+ if (!vendor || !isDirectProvider(vendor)) { continue; } // gateway-only vendor: no direct route ever possible
86
+ const token = bareSegment(forms.openrouter, vendor);
87
+ if (!token) { continue; }
88
+ const paired = pairAcrossGateways(vendor, token, catalogInfo); // Task-5 contract: bare segment only
89
+ if (!paired.direct || !isAuthoritative(catalogInfo, paired.direct)) { continue; } // unconfirmed -- never guess
90
+
91
+ if (!forms.direct) {
92
+ findings.push({ alias, gateway: 'direct', kind: 'divergent-missing', model: paired.direct });
93
+ } else if (forms.direct !== paired.direct) {
94
+ findings.push({
95
+ alias, gateway: 'direct', kind: 'divergent-mismatch', model: forms.direct, expected: paired.direct
96
+ });
97
+ }
98
+ }
99
+
100
+ return findings;
101
+ }
102
+
103
+ module.exports = { auditGatewayRoutes };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Conservative cross-gateway catalog pairing helper (Task 5, #gwid).
3
+ *
4
+ * The same model has a different id per gateway namespace: direct
5
+ * `anthropic/claude-opus-4-8` (dashes, sometimes a trailing date suffix)
6
+ * vs. OpenRouter `openrouter/anthropic/claude-opus-4.8` (dots). This module
7
+ * pairs the two rows for a given vendor + version token, using a normalized
8
+ * comparison key ONLY to decide whether two catalog rows refer to the same
9
+ * model -- it never derives, transforms, or invents an id. Every id this
10
+ * module returns is copied verbatim from `catalogInfo.models[].id`.
11
+ *
12
+ * Used ONLY by `amicus models --check` (Task 6) to audit/refresh the curated
13
+ * per-gateway route map -- NOT on any launch hot path. Correctness-when-
14
+ * uncertain matters more than cleverness here: when a namespace has zero or
15
+ * more than one plausible match, that side is OMITTED rather than guessed.
16
+ *
17
+ * Pure function: no I/O, no network, no catalog fetch.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const OPENROUTER_PREFIX = 'openrouter/';
23
+ /** Trailing 8-digit date suffix (e.g. '-20251001'), comparison-only. */
24
+ const TRAILING_DATE_RE = /-\d{8}$/;
25
+
26
+ /**
27
+ * Normalize a catalog id or a caller-supplied version token into a
28
+ * comparison-only key: strip a leading `openrouter/`, strip a leading
29
+ * `<vendor>/`, lowercase, unify '.'/'-' separators (dots become dashes), and
30
+ * drop a trailing 8-digit date suffix. The result is NEVER returned to
31
+ * callers -- it exists solely to decide whether two strings name the same
32
+ * model.
33
+ * @param {string} raw
34
+ * @param {string} vendor
35
+ * @returns {string|null} normalized key, or null when `raw` isn't a string
36
+ */
37
+ function normalizeKey(raw, vendor) {
38
+ if (typeof raw !== 'string' || raw.length === 0) { return null; }
39
+ let s = raw;
40
+ if (s.startsWith(OPENROUTER_PREFIX)) { s = s.slice(OPENROUTER_PREFIX.length); }
41
+ const vendorPrefix = `${vendor}/`;
42
+ if (s.startsWith(vendorPrefix)) { s = s.slice(vendorPrefix.length); }
43
+ s = s.toLowerCase().replace(/\./g, '-');
44
+ s = s.replace(TRAILING_DATE_RE, '');
45
+ return s;
46
+ }
47
+
48
+ /**
49
+ * Find, in `catalogInfo.models`, the direct-namespace id and the
50
+ * OpenRouter-namespace id that both correspond to the model named by
51
+ * `versionToken` for `vendor`.
52
+ *
53
+ * Matching is conservative: a side is only included when EXACTLY ONE row in
54
+ * that namespace normalizes to the same key as `versionToken`. Zero matches
55
+ * or more than one plausible match (ambiguous) both result in that side
56
+ * being omitted -- never a guessed/fuzzy pick.
57
+ *
58
+ * @param {string} vendor e.g. 'anthropic'
59
+ * @param {string} versionToken e.g. 'claude-opus-4-8' or 'claude-opus-4.8'
60
+ * @param {{models: Array<{id: string}>}} catalogInfo
61
+ * @returns {{direct?: string, openrouter?: string}} verbatim catalog ids only
62
+ */
63
+ function pairAcrossGateways(vendor, versionToken, catalogInfo) {
64
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
65
+ const targetKey = normalizeKey(versionToken, vendor);
66
+ const result = {};
67
+ if (targetKey === null) { return result; }
68
+
69
+ const directPrefix = `${vendor}/`;
70
+ const openrouterPrefix = `${OPENROUTER_PREFIX}${vendor}/`;
71
+
72
+ const directMatches = [];
73
+ const openrouterMatches = [];
74
+
75
+ for (const row of models) {
76
+ if (!row || typeof row.id !== 'string') { continue; }
77
+ const id = row.id;
78
+ if (id.startsWith(openrouterPrefix)) {
79
+ if (normalizeKey(id, vendor) === targetKey) { openrouterMatches.push(id); }
80
+ } else if (id.startsWith(directPrefix)) {
81
+ if (normalizeKey(id, vendor) === targetKey) { directMatches.push(id); }
82
+ }
83
+ }
84
+
85
+ // Exactly one candidate required per side -- ambiguity (>1) is treated the
86
+ // same as absence (0): omit rather than guess.
87
+ if (directMatches.length === 1) { result.direct = directMatches[0]; }
88
+ if (openrouterMatches.length === 1) { result.openrouter = openrouterMatches[0]; }
89
+ return result;
90
+ }
91
+
92
+ module.exports = { pairAcrossGateways };
@@ -0,0 +1,131 @@
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). Wired into live launch
5
+ * paths via route-launch.js's resolveRouteForLaunch (start-helpers.js,
6
+ * mcp-server.js, sidecar/fanout-validate.js).
7
+ */
8
+ 'use strict';
9
+
10
+ const { resolved, routeError, selectionRequired, parseDescriptor } = require('./model-descriptor');
11
+ const { classifyModel } = require('./model-classification');
12
+ const { isDirectProvider } = require('./provider-registry');
13
+
14
+ /** Build the executable id for a gateway. */
15
+ function executableFor(gateway, vendor, model) {
16
+ return gateway === 'openrouter' ? `openrouter/${vendor}/${model}` : `${vendor}/${model}`;
17
+ }
18
+
19
+ /**
20
+ * Catalog gate: returns { ok:true, notice? } to proceed, or { ok:false, result }
21
+ * carrying a selection_required/error to return to the caller.
22
+ */
23
+ function catalogGate({ id, gateway, req }) {
24
+ if (req.validateModel === false) {
25
+ return { ok: true, notice: 'Model availability not validated (--no-validate-model).' };
26
+ }
27
+ const verdict = classifyModel(id, gateway, req.catalogInfo);
28
+ if (verdict === 'valid') { return { ok: true }; }
29
+ if (verdict === 'unknown') {
30
+ return { ok: true, notice: `Model '${id}' is unverified against the ${gateway} catalog; attempting anyway.` };
31
+ }
32
+ // invalid
33
+ if (req.allowSelection) {
34
+ return { ok: false, result: selectionRequired({ requested: req.descriptor.raw, suggestions: [] }) };
35
+ }
36
+ return { ok: false, result: routeError({ requested: req.descriptor.raw, reason: 'model_not_found',
37
+ preferredGateway: gateway, suggestions: [] }) };
38
+ }
39
+
40
+ /**
41
+ * True when the given gateway is usable for this request: either the caller
42
+ * didn't supply per-gateway ids at all (back-compat: nothing to check), or it
43
+ * did and this specific gateway has a form in it.
44
+ */
45
+ function hasForm(req, gateway) {
46
+ return !req.gatewayIds || req.gatewayIds[gateway] !== undefined;
47
+ }
48
+
49
+ /** Resolve to a concrete gateway after the catalog gate passes. */
50
+ function finish(gateway, vendor, model, req) {
51
+ const id = (req.gatewayIds && req.gatewayIds[gateway]) || executableFor(gateway, vendor, model);
52
+ const gate = catalogGate({ id, gateway, req });
53
+ if (!gate.ok) { return gate.result; }
54
+ return resolved({ model: id, gateway, executableId: id,
55
+ provenance: { source: req.source, requested: req.descriptor.raw, gatewayMode: req.gatewayMode }, notice: gate.notice });
56
+ }
57
+
58
+ /**
59
+ * @param {object} req see Task 5 Interfaces
60
+ * @returns RouteResult
61
+ */
62
+ function resolveRoute(req) {
63
+ // 1. Normalize: req.descriptor may arrive as a parsed Descriptor object or a
64
+ // raw canonical/OR-literal id string. Only canonical/openrouter-literal kinds
65
+ // are routable; alias/invalid/garbage descriptors are rejected right here,
66
+ // before any branch below touches vendor/model. All downstream code reads
67
+ // from the normalized `d` (via the cloned `rq`), never from req.descriptor.
68
+ let d = req.descriptor;
69
+ if (typeof d === 'string') {
70
+ d = parseDescriptor(d, { aliases: {} });
71
+ }
72
+ if (!d || (d.kind !== 'canonical' && d.kind !== 'openrouter-literal')) {
73
+ const requested = d ? d.raw : (typeof req.descriptor === 'string' ? req.descriptor : String(req && req.descriptor));
74
+ return routeError({ requested, reason: 'invalid_descriptor', preferredGateway: req.gatewayMode, suggestions: [] });
75
+ }
76
+ const rq = { ...req, descriptor: d };
77
+ const vendor = d.vendor;
78
+ const model = d.model;
79
+
80
+ // 2. Explicit conflict: force-OR literal vs --gateway direct
81
+ if (d.isExplicitOpenRouter && rq.gatewayMode === 'direct') {
82
+ return routeError({ requested: d.raw, reason: 'gateway_conflict', preferredGateway: 'direct', suggestions: [] });
83
+ }
84
+ // 3. Explicit OR literal
85
+ if (d.isExplicitOpenRouter) {
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
+ // 4. Gateway-only vendor (no direct integration)
92
+ if (!isDirectProvider(vendor)) {
93
+ if (rq.gatewayMode === 'direct') {
94
+ return routeError({ requested: d.raw, reason: 'no_direct_integration', preferredGateway: 'direct', suggestions: [] });
95
+ }
96
+ if (!rq.keys.openrouter) {
97
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
98
+ }
99
+ return finish('openrouter', vendor, model, rq);
100
+ }
101
+ // 5. Explicit --gateway openrouter
102
+ if (rq.gatewayMode === 'openrouter') {
103
+ if (!rq.keys.openrouter) {
104
+ return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
105
+ }
106
+ if (!hasForm(rq, 'openrouter')) {
107
+ return routeError({ requested: d.raw, reason: 'openrouter_unavailable', preferredGateway: 'openrouter', suggestions: [] });
108
+ }
109
+ return finish('openrouter', vendor, model, rq);
110
+ }
111
+ // 6. Explicit --gateway direct
112
+ if (rq.gatewayMode === 'direct') {
113
+ if (!rq.keys[vendor]) {
114
+ return routeError({ requested: d.raw, reason: 'no_direct_key', preferredGateway: 'direct', suggestions: [] });
115
+ }
116
+ if (!hasForm(rq, 'direct')) {
117
+ return routeError({ requested: d.raw, reason: 'direct_unavailable', preferredGateway: 'direct', suggestions: [] });
118
+ }
119
+ return finish('direct', vendor, model, rq);
120
+ }
121
+ // 7. auto (direct-first)
122
+ if (rq.keys[vendor] && hasForm(rq, 'direct')) {
123
+ return finish('direct', vendor, model, rq);
124
+ }
125
+ if (rq.keys.openrouter && hasForm(rq, 'openrouter')) {
126
+ return finish('openrouter', vendor, model, rq);
127
+ }
128
+ return routeError({ requested: d.raw, reason: 'no_key_for_vendor', preferredGateway: 'direct', suggestions: [] });
129
+ }
130
+
131
+ 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 };