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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +66 -0
- package/README.md +13 -4
- package/electron/setup-ui-aliases.js +1 -1
- package/electron/setup-ui.js +27 -3
- package/package.json +2 -1
- package/skills/second-opinion/SKILL.md +2 -0
- package/skills/sidecar/SKILL.md +66 -38
- package/src/cli-handlers-resume-continue.js +31 -4
- package/src/cli-handlers-run.js +15 -4
- package/src/cli.js +17 -0
- package/src/mcp-server.js +99 -12
- package/src/mcp-tools.js +26 -4
- package/src/opencode-client.js +18 -2
- package/src/sidecar/continue.js +10 -3
- package/src/sidecar/fanout-leg.js +26 -1
- package/src/sidecar/fanout-output.js +5 -0
- package/src/sidecar/fanout-validate.js +81 -0
- package/src/sidecar/fanout.js +65 -77
- package/src/sidecar/models.js +39 -17
- package/src/sidecar/session-utils.js +6 -0
- package/src/sidecar/setup.js +2 -1
- package/src/utils/alias-resolver.js +6 -35
- package/src/utils/api-key-store.js +1 -9
- package/src/utils/auth-json.js +1 -1
- package/src/utils/config.js +98 -16
- package/src/utils/curated-models.js +99 -8
- package/src/utils/gateway-route-audit.js +103 -0
- package/src/utils/gateway-route-catalog.js +92 -0
- package/src/utils/gateway-router.js +131 -0
- package/src/utils/input-validators.js +12 -42
- package/src/utils/model-classification.js +65 -0
- package/src/utils/model-descriptor.js +72 -0
- package/src/utils/model-fetcher.js +46 -14
- package/src/utils/model-input-default.js +32 -0
- package/src/utils/model-validator.js +68 -84
- package/src/utils/provider-registry.js +57 -0
- package/src/utils/quick-picks.js +11 -3
- package/src/utils/result-schema-rebuild.js +98 -0
- package/src/utils/result-schema.js +15 -78
- package/src/utils/route-error.js +154 -0
- package/src/utils/route-launch.js +219 -0
- package/src/utils/start-helpers.js +96 -43
- 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-
|
|
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-
|
|
66
|
-
|
|
67
|
-
{ alias: '
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
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 };
|