amicus 3.0.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 +43 -0
- package/README.md +12 -3
- package/electron/setup-ui.js +27 -3
- package/package.json +1 -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 +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/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/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
|
@@ -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
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module model-input-default
|
|
5
|
+
* Shared "no --model given" default-lookup, extracted so it lives in exactly
|
|
6
|
+
* one place (#61 Task 6.2 review follow-up): both the CLI's resolveLaunchModel
|
|
7
|
+
* (start-helpers.js) and the MCP amicus_start handler (mcp-server.js) need to
|
|
8
|
+
* fall back to the configured default before handing a model off to
|
|
9
|
+
* resolveRouteForLaunch — without it, an omitted model reaches
|
|
10
|
+
* resolveRouteForLaunch as undefined -> parseDescriptor(undefined) -> an
|
|
11
|
+
* `invalid` descriptor, breaking the common "no --model" launch case.
|
|
12
|
+
* A leaf module (no other src/ module requires/mocks it), so introducing it
|
|
13
|
+
* doesn't reshape any existing jest.doMock('../src/utils/route-launch', ...)
|
|
14
|
+
* or jest.doMock('../src/utils/config', ...) call shape.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the raw model input for a launch: an explicit value (including one
|
|
19
|
+
* a caller may have already normalized) passes through unchanged; otherwise
|
|
20
|
+
* falls back to the configured default.
|
|
21
|
+
* @param {string|null|undefined} inputModel
|
|
22
|
+
* @returns {string|undefined} inputModel as-is, the configured default, or
|
|
23
|
+
* undefined if neither exists (the caller decides how to report that).
|
|
24
|
+
*/
|
|
25
|
+
function resolveModelInputOrDefault(inputModel) {
|
|
26
|
+
if (inputModel !== undefined && inputModel !== null) { return inputModel; }
|
|
27
|
+
const { loadConfig } = require('./config');
|
|
28
|
+
const cfg = loadConfig();
|
|
29
|
+
return (cfg && cfg.default) ? cfg.default : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { resolveModelInputOrDefault };
|
|
@@ -7,10 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const readline = require('readline');
|
|
10
|
-
const { fetchModelsFromProvider } = require('./model-fetcher');
|
|
11
|
-
const { readApiKeyValues } = require('./api-key-store');
|
|
12
10
|
const { loadConfig, saveConfig, getConfigPath } = require('./config');
|
|
13
|
-
const { logger } = require('./logger');
|
|
14
11
|
|
|
15
12
|
/**
|
|
16
13
|
* Normalize a model ID to include the provider prefix.
|
|
@@ -33,54 +30,6 @@ const ALIAS_SEARCH_TERMS = {
|
|
|
33
30
|
'deepseek': 'deepseek',
|
|
34
31
|
};
|
|
35
32
|
|
|
36
|
-
/**
|
|
37
|
-
* Validate a direct-API fallback model exists on the provider.
|
|
38
|
-
* Returns silently if valid. On failure: prompts (interactive) or throws (headless).
|
|
39
|
-
*
|
|
40
|
-
* @param {string} resolvedModel - e.g. 'google/gemini-3.1-flash-lite-preview'
|
|
41
|
-
* @param {string} alias - Original alias name (e.g. 'gemini')
|
|
42
|
-
* @param {object} [options]
|
|
43
|
-
* @param {boolean} [options.headless] - If true, throw instead of prompting
|
|
44
|
-
* @returns {Promise<string>} Confirmed model string
|
|
45
|
-
*/
|
|
46
|
-
async function validateDirectModel(resolvedModel, alias, options = {}) {
|
|
47
|
-
const parts = resolvedModel.split('/');
|
|
48
|
-
if (parts.length < 2) { return resolvedModel; }
|
|
49
|
-
|
|
50
|
-
const provider = parts[0];
|
|
51
|
-
const modelId = parts.slice(1).join('/');
|
|
52
|
-
|
|
53
|
-
const keys = readApiKeyValues();
|
|
54
|
-
const providerKey = keys[provider];
|
|
55
|
-
if (!providerKey) { return resolvedModel; }
|
|
56
|
-
|
|
57
|
-
let models;
|
|
58
|
-
try {
|
|
59
|
-
models = await fetchModelsFromProvider(provider, providerKey);
|
|
60
|
-
} catch (err) {
|
|
61
|
-
logger.debug({ msg: 'Model fetch failed, skipping validation', error: err.message });
|
|
62
|
-
return resolvedModel;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (!models || models.length === 0) { return resolvedModel; }
|
|
66
|
-
|
|
67
|
-
const found = models.some(m => m.id === resolvedModel || m.id === modelId);
|
|
68
|
-
if (found) { return resolvedModel; }
|
|
69
|
-
|
|
70
|
-
const relevant = filterRelevantModels(models, alias);
|
|
71
|
-
|
|
72
|
-
if (options.headless || !process.stdin.isTTY) {
|
|
73
|
-
const list = relevant.slice(0, 10).map(m => ` ${normalizeModelId(provider, m.id)}`).join('\n');
|
|
74
|
-
throw new Error(
|
|
75
|
-
`Model '${modelId}' not found on ${provider} API.\n` +
|
|
76
|
-
`Available models:\n${list}\n` +
|
|
77
|
-
`Fix with: amicus setup --add-alias ${alias}=${relevant[0] ? normalizeModelId(provider, relevant[0].id) : 'provider/model'}`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return promptModelSelection(relevant, alias, provider, modelId);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
33
|
/**
|
|
85
34
|
* Filter models to those relevant to the alias
|
|
86
35
|
* @param {Array<{id: string, name: string}>} models
|
|
@@ -101,54 +50,83 @@ function filterRelevantModels(models, alias) {
|
|
|
101
50
|
return filtered.slice(0, 15);
|
|
102
51
|
}
|
|
103
52
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Interactive alternatives picker for a direct-model miss (#61 Task 6.3, spec
|
|
55
|
+
* Decision 10). Presents `selectionResult.suggestions` (built upstream by
|
|
56
|
+
* route-launch.js's buildSuggestions) as a labeled numbered menu and lets the
|
|
57
|
+
* user pick one, or cancel. Uses the same readline + persist pattern (same
|
|
58
|
+
* save-with-malformed-config guard) as other model pickers in this module,
|
|
59
|
+
* adapted to the `{model, gateway, note}` suggestion shape instead of
|
|
60
|
+
* provider `{id, name}` rows.
|
|
61
|
+
*
|
|
62
|
+
* Never auto-selects — even a single suggestion still requires an explicit
|
|
63
|
+
* pick. Cancellation (empty input, an out-of-range number, or no suggestions
|
|
64
|
+
* to offer) always throws; the caller (resolveLaunchModel) is expected to
|
|
65
|
+
* catch and translate that into a "cancelled" stderr message + exit(1).
|
|
66
|
+
*
|
|
67
|
+
* @param {{requested: string, suggestions: Array<{model:string, gateway:string, note?:string}>}} selectionResult
|
|
68
|
+
* @param {string|undefined} alias - alias to persist the choice under, if any
|
|
69
|
+
* @returns {Promise<{model: string, gateway: string}>}
|
|
70
|
+
*/
|
|
71
|
+
async function promptRouteSelection(selectionResult, alias) {
|
|
72
|
+
const suggestions = (selectionResult && Array.isArray(selectionResult.suggestions))
|
|
73
|
+
? selectionResult.suggestions : [];
|
|
74
|
+
const requested = selectionResult && selectionResult.requested;
|
|
75
|
+
|
|
76
|
+
process.stderr.write(`\n Model '${requested}' isn't available on the direct API.\n`);
|
|
77
|
+
|
|
78
|
+
if (suggestions.length === 0) {
|
|
79
|
+
process.stderr.write(' No alternatives available.\n\n');
|
|
80
|
+
throw new Error('Model selection cancelled.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.stderr.write(' Alternatives:\n');
|
|
84
|
+
suggestions.forEach((s, i) => {
|
|
85
|
+
const note = s && s.note ? ` — ${s.note}` : '';
|
|
86
|
+
process.stderr.write(` ${i + 1}. ${s && s.model} [${s && s.gateway}]${note}\n`);
|
|
111
87
|
});
|
|
112
88
|
process.stderr.write('\n');
|
|
113
89
|
|
|
114
90
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
115
91
|
|
|
116
92
|
const answer = await new Promise(resolve => {
|
|
117
|
-
rl.question(` Select
|
|
93
|
+
rl.question(` Select (1-${suggestions.length}) or Enter to cancel: `, resolve);
|
|
118
94
|
});
|
|
119
95
|
rl.close();
|
|
120
96
|
|
|
121
97
|
const idx = parseInt(answer, 10) - 1;
|
|
122
|
-
if (isNaN(idx) || idx < 0 || idx >=
|
|
98
|
+
if (isNaN(idx) || idx < 0 || idx >= suggestions.length) {
|
|
123
99
|
throw new Error('Model selection cancelled.');
|
|
124
100
|
}
|
|
125
101
|
|
|
126
|
-
const
|
|
127
|
-
const newModel =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
102
|
+
const chosen = suggestions[idx];
|
|
103
|
+
const newModel = chosen.model;
|
|
104
|
+
|
|
105
|
+
if (alias) {
|
|
106
|
+
let config = loadConfig();
|
|
107
|
+
if (!config) {
|
|
108
|
+
const fs = require('fs');
|
|
109
|
+
const configPath = getConfigPath();
|
|
110
|
+
if (fs.existsSync(configPath)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Cannot save model selection: config file at ${configPath} is malformed. ` +
|
|
113
|
+
'Fix it manually or run \'amicus setup\'.'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
config = {};
|
|
117
|
+
}
|
|
118
|
+
if (!config.aliases) { config.aliases = {}; }
|
|
119
|
+
config.aliases[alias] = newModel;
|
|
120
|
+
try {
|
|
121
|
+
saveConfig(config);
|
|
122
|
+
process.stderr.write(` Saved: ${alias} -> ${newModel}\n`);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
|
|
138
125
|
}
|
|
139
|
-
config = {};
|
|
140
|
-
}
|
|
141
|
-
if (!config.aliases) { config.aliases = {}; }
|
|
142
|
-
config.aliases[alias] = newModel;
|
|
143
|
-
try {
|
|
144
|
-
saveConfig(config);
|
|
145
|
-
process.stderr.write(` Saved: ${alias} → ${newModel}\n`);
|
|
146
|
-
} catch (err) {
|
|
147
|
-
process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
|
|
148
126
|
}
|
|
149
|
-
process.stderr.write(
|
|
127
|
+
process.stderr.write('\n');
|
|
150
128
|
|
|
151
|
-
return newModel;
|
|
129
|
+
return { model: newModel, gateway: chosen.gateway };
|
|
152
130
|
}
|
|
153
131
|
|
|
154
132
|
/**
|
|
@@ -204,4 +182,10 @@ async function warnIfNotInCatalog(model) {
|
|
|
204
182
|
}
|
|
205
183
|
}
|
|
206
184
|
|
|
207
|
-
module.exports = {
|
|
185
|
+
module.exports = {
|
|
186
|
+
filterRelevantModels,
|
|
187
|
+
normalizeModelId,
|
|
188
|
+
validateAgainstCatalog,
|
|
189
|
+
warnIfNotInCatalog,
|
|
190
|
+
promptRouteSelection,
|
|
191
|
+
};
|