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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +13 -8
- package/bin/amicus.js +5 -0
- package/electron/close-guard.js +4 -4
- package/electron/fold.js +8 -8
- package/electron/ipc-guard.js +3 -3
- package/electron/main.js +31 -31
- package/electron/opencode-theme.js +3 -3
- package/electron/preload-content.js +1 -1
- package/electron/setup-ui.js +27 -3
- package/package.json +4 -3
- 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 +13 -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/electron-install.js +9 -8
- 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/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 +33 -4
- package/src/utils/gateway-router.js +115 -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 +35 -9
- package/src/utils/model-input-default.js +32 -0
- package/src/utils/model-validator.js +68 -84
- package/src/utils/node-version-guard.js +16 -0
- 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 +6 -76
- package/src/utils/route-error.js +137 -0
- package/src/utils/route-launch.js +179 -0
- package/src/utils/start-helpers.js +96 -43
- package/src/utils/validators.js +1 -8
package/src/utils/config.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
245
|
-
if (!fullModel || typeof fullModel !== 'string') {
|
|
274
|
+
const addRoute = (fullModel) => {
|
|
275
|
+
if (!fullModel || typeof fullModel !== 'string') { return; }
|
|
246
276
|
const parts = fullModel.split('/');
|
|
247
|
-
if (parts.length < 2) {
|
|
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
|
-
|
|
259
|
-
|
|
303
|
+
for (const resolved of resolvedRoutes) {
|
|
304
|
+
addRoute(resolved);
|
|
305
|
+
}
|
|
260
306
|
|
|
261
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|
|
@@ -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
|
-
|
|
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
|
|