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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-capability registry — the single source of truth for provider
|
|
3
|
+
* identity, credentials, direct-vs-gateway role, and display names.
|
|
4
|
+
* The historical maps (PROVIDER_ENV_MAP, PROVIDER_KEY_MAP, KNOWN_PROVIDERS,
|
|
5
|
+
* PROVIDER_FAMILY_NAMES) are DERIVED from PROVIDERS below so they can never
|
|
6
|
+
* drift apart again. Leaf module: requires nothing internal (no circular deps).
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} ProviderDescriptor
|
|
12
|
+
* @property {string} id provider id (namespace)
|
|
13
|
+
* @property {string} envVar env var holding the key
|
|
14
|
+
* @property {string} keyDisplayName human name used in missing-key errors
|
|
15
|
+
* @property {string} familyName short name used for optgroup grouping
|
|
16
|
+
* @property {boolean} direct can be a DIRECT route target (false for the gateway)
|
|
17
|
+
* @property {boolean} gateway is the OpenRouter gateway itself
|
|
18
|
+
* @property {boolean} hasLiveFetch has a live GET /models endpoint
|
|
19
|
+
* @property {string} authJsonKey key used in OpenCode auth.json
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @type {ProviderDescriptor[]} */
|
|
23
|
+
const PROVIDERS = [
|
|
24
|
+
{ id: 'openrouter', envVar: 'OPENROUTER_API_KEY', keyDisplayName: 'OpenRouter', familyName: 'OpenRouter', direct: false, gateway: true, hasLiveFetch: true, authJsonKey: 'openrouter' },
|
|
25
|
+
{ id: 'google', envVar: 'GOOGLE_GENERATIVE_AI_API_KEY', keyDisplayName: 'Google Gemini', familyName: 'Google', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'google' },
|
|
26
|
+
{ id: 'openai', envVar: 'OPENAI_API_KEY', keyDisplayName: 'OpenAI', familyName: 'OpenAI', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'openai' },
|
|
27
|
+
{ id: 'anthropic', envVar: 'ANTHROPIC_API_KEY', keyDisplayName: 'Anthropic', familyName: 'Anthropic', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'anthropic' },
|
|
28
|
+
{ id: 'deepseek', envVar: 'DEEPSEEK_API_KEY', keyDisplayName: 'DeepSeek', familyName: 'DeepSeek', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'deepseek' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const _byId = new Map(PROVIDERS.map(p => [p.id, p]));
|
|
32
|
+
|
|
33
|
+
/** @param {string} id @returns {ProviderDescriptor|undefined} */
|
|
34
|
+
function getProvider(id) { return _byId.get(id); }
|
|
35
|
+
|
|
36
|
+
/** @param {string} id @returns {boolean} true only for direct-route vendors (never the gateway) */
|
|
37
|
+
function isDirectProvider(id) { const p = _byId.get(id); return !!p && p.direct; }
|
|
38
|
+
|
|
39
|
+
/** @returns {string[]} ids of direct-route vendors (excludes openrouter) */
|
|
40
|
+
function listDirectProviders() { return PROVIDERS.filter(p => p.direct).map(p => p.id); }
|
|
41
|
+
|
|
42
|
+
// --- Derived compatibility maps (do not hand-edit; edit PROVIDERS above) ---
|
|
43
|
+
const PROVIDER_ENV_MAP = Object.fromEntries(PROVIDERS.map(p => [p.id, p.envVar]));
|
|
44
|
+
const PROVIDER_KEY_MAP = Object.fromEntries(PROVIDERS.map(p => [p.id, { key: p.envVar, name: p.keyDisplayName }]));
|
|
45
|
+
const KNOWN_PROVIDERS = PROVIDERS.map(p => p.id);
|
|
46
|
+
const PROVIDER_FAMILY_NAMES = Object.fromEntries(PROVIDERS.map(p => [p.id, p.familyName]));
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
PROVIDERS,
|
|
50
|
+
getProvider,
|
|
51
|
+
isDirectProvider,
|
|
52
|
+
listDirectProviders,
|
|
53
|
+
PROVIDER_ENV_MAP,
|
|
54
|
+
PROVIDER_KEY_MAP,
|
|
55
|
+
KNOWN_PROVIDERS,
|
|
56
|
+
PROVIDER_FAMILY_NAMES,
|
|
57
|
+
};
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const { getFamilies, toDefaultAliases } = require('./curated-models');
|
|
13
|
+
const { getFamilies, toDefaultAliases, toCanonicalDefault } = require('./curated-models');
|
|
14
14
|
|
|
15
15
|
const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
|
|
16
16
|
|
|
@@ -67,13 +67,21 @@ function resolveQuickPicks(catalog) {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Seed map for fresh configs: static defaults overlaid with live family
|
|
70
|
-
*
|
|
70
|
+
* routes (cardless aliases stay pinned). The overlaid route is run through
|
|
71
|
+
* `toCanonicalDefault` so a direct-capable vendor (e.g. google, openai)
|
|
72
|
+
* lands as bare `vendor/model` (direct-first via the gateway router)
|
|
73
|
+
* instead of the raw `openrouter/<vendor>/<rest>` pick — otherwise a fresh
|
|
74
|
+
* `amicus setup` with a live catalog would silently defeat the direct-first
|
|
75
|
+
* default `toDefaultAliases()` establishes. Gateway-only vendors are
|
|
76
|
+
* returned unchanged by `toCanonicalDefault`.
|
|
71
77
|
* @returns {Object<string,string>}
|
|
72
78
|
*/
|
|
73
79
|
function toLiveSeedAliases(catalog) {
|
|
74
80
|
const seeds = toDefaultAliases();
|
|
75
81
|
for (const r of resolveQuickPicks(catalog || [])) {
|
|
76
|
-
if (r.source === 'live' && r.routes.openrouter) {
|
|
82
|
+
if (r.source === 'live' && r.routes.openrouter) {
|
|
83
|
+
seeds[r.alias] = toCanonicalDefault(r.routes.openrouter);
|
|
84
|
+
}
|
|
77
85
|
}
|
|
78
86
|
return seeds;
|
|
79
87
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module result-schema-rebuild
|
|
5
|
+
* Rebuild run/wave documents from persisted session directories (as opposed to
|
|
6
|
+
* `result-schema.js`'s builders, which assemble a document from in-memory
|
|
7
|
+
* state right after a run/wave finishes). Split out of result-schema.js to
|
|
8
|
+
* stay under the 300-line size gate (#61 whole-branch review housekeeping) —
|
|
9
|
+
* re-exported from result-schema.js so every existing caller's import path is
|
|
10
|
+
* unaffected.
|
|
11
|
+
*
|
|
12
|
+
* `buildRunResult`/`buildWaveResult` are required LAZILY inside each function
|
|
13
|
+
* body (not at module load time) so this file can depend on result-schema.js
|
|
14
|
+
* without a circular-require ordering hazard: result-schema.js requires this
|
|
15
|
+
* module too (to re-export these two functions), and a top-level require here
|
|
16
|
+
* would see result-schema.js's exports mid-assembly (see result-schema.js's
|
|
17
|
+
* module doc for why abort-result.js already avoids the same trap).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rebuild a run document from a persisted session directory.
|
|
22
|
+
* @param {string} project - Project dir
|
|
23
|
+
* @param {string} taskId
|
|
24
|
+
* @returns {object} run document
|
|
25
|
+
* @throws {Error} if the session does not exist or metadata.json is missing/corrupt
|
|
26
|
+
*/
|
|
27
|
+
function buildRunResultFromSession(project, taskId) {
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const { resolveExistingSessionDir } = require('../session-manager');
|
|
31
|
+
const { buildRunResult } = require('./result-schema');
|
|
32
|
+
const sessionDir = resolveExistingSessionDir(project, taskId);
|
|
33
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
34
|
+
if (!fs.existsSync(metaPath)) {
|
|
35
|
+
throw new Error(`Session ${taskId} not found`);
|
|
36
|
+
}
|
|
37
|
+
let metadata;
|
|
38
|
+
try {
|
|
39
|
+
metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
|
|
42
|
+
}
|
|
43
|
+
const summaryPath = path.join(sessionDir, 'summary.md');
|
|
44
|
+
const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
|
|
45
|
+
return buildRunResult({ taskId, metadata, summary, sessionDir });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rebuild a wave document. Prefers the stored wave.json (written atomically at
|
|
50
|
+
* fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
|
|
51
|
+
* hard kill of the fanout process).
|
|
52
|
+
* @param {string} project
|
|
53
|
+
* @param {string} waveId
|
|
54
|
+
* @returns {object} wave document
|
|
55
|
+
* @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
|
|
56
|
+
*/
|
|
57
|
+
function buildWaveResultFromSession(project, waveId) {
|
|
58
|
+
const fs = require('fs');
|
|
59
|
+
const path = require('path');
|
|
60
|
+
const { resolveExistingSessionDir } = require('../session-manager');
|
|
61
|
+
const { buildRunResult, buildWaveResult } = require('./result-schema');
|
|
62
|
+
const waveDir = resolveExistingSessionDir(project, waveId);
|
|
63
|
+
const wavePath = path.join(waveDir, 'wave.json');
|
|
64
|
+
if (fs.existsSync(wavePath)) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
|
|
67
|
+
} catch {
|
|
68
|
+
// Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const metaPath = path.join(waveDir, 'metadata.json');
|
|
72
|
+
if (!fs.existsSync(metaPath)) {
|
|
73
|
+
throw new Error(`Wave ${waveId} not found`);
|
|
74
|
+
}
|
|
75
|
+
let meta;
|
|
76
|
+
try {
|
|
77
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
|
|
80
|
+
}
|
|
81
|
+
const legs = (meta.legs || []).map((legId) => {
|
|
82
|
+
try { return buildRunResultFromSession(project, legId); }
|
|
83
|
+
catch (err) {
|
|
84
|
+
const { logger } = require('./logger');
|
|
85
|
+
logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
|
|
86
|
+
return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return buildWaveResult({
|
|
90
|
+
waveId,
|
|
91
|
+
legs,
|
|
92
|
+
promptMeta: meta.promptMeta || null,
|
|
93
|
+
createdAt: meta.createdAt || null,
|
|
94
|
+
completedAt: meta.completedAt || null,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { buildRunResultFromSession, buildWaveResultFromSession };
|
|
@@ -121,9 +121,10 @@ function waveExitCode(waveStatus) {
|
|
|
121
121
|
* @param {string|null} [opts.createdAt]
|
|
122
122
|
* @param {string|null} [opts.completedAt]
|
|
123
123
|
* @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
|
|
124
|
+
* @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
|
|
124
125
|
* @returns {object} wave document
|
|
125
126
|
*/
|
|
126
|
-
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
|
|
127
|
+
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
|
|
127
128
|
const { sumWaveUsage } = require('./pricing');
|
|
128
129
|
// Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
|
|
129
130
|
// 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
|
|
@@ -149,84 +150,13 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
|
|
|
149
150
|
completedAt,
|
|
150
151
|
durationMs,
|
|
151
152
|
usage: sumWaveUsage(legs),
|
|
153
|
+
notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
* @param {string} taskId
|
|
159
|
-
* @returns {object} run document
|
|
160
|
-
* @throws {Error} if the session does not exist or metadata.json is missing/corrupt
|
|
161
|
-
*/
|
|
162
|
-
function buildRunResultFromSession(project, taskId) {
|
|
163
|
-
const fs = require('fs');
|
|
164
|
-
const path = require('path');
|
|
165
|
-
const { resolveExistingSessionDir } = require('../session-manager');
|
|
166
|
-
const sessionDir = resolveExistingSessionDir(project, taskId);
|
|
167
|
-
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
168
|
-
if (!fs.existsSync(metaPath)) {
|
|
169
|
-
throw new Error(`Session ${taskId} not found`);
|
|
170
|
-
}
|
|
171
|
-
let metadata;
|
|
172
|
-
try {
|
|
173
|
-
metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
174
|
-
} catch (err) {
|
|
175
|
-
throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
|
|
176
|
-
}
|
|
177
|
-
const summaryPath = path.join(sessionDir, 'summary.md');
|
|
178
|
-
const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
|
|
179
|
-
return buildRunResult({ taskId, metadata, summary, sessionDir });
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Rebuild a wave document. Prefers the stored wave.json (written atomically at
|
|
184
|
-
* fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
|
|
185
|
-
* hard kill of the fanout process).
|
|
186
|
-
* @param {string} project
|
|
187
|
-
* @param {string} waveId
|
|
188
|
-
* @returns {object} wave document
|
|
189
|
-
* @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
|
|
190
|
-
*/
|
|
191
|
-
function buildWaveResultFromSession(project, waveId) {
|
|
192
|
-
const fs = require('fs');
|
|
193
|
-
const path = require('path');
|
|
194
|
-
const { resolveExistingSessionDir } = require('../session-manager');
|
|
195
|
-
const waveDir = resolveExistingSessionDir(project, waveId);
|
|
196
|
-
const wavePath = path.join(waveDir, 'wave.json');
|
|
197
|
-
if (fs.existsSync(wavePath)) {
|
|
198
|
-
try {
|
|
199
|
-
return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
|
|
200
|
-
} catch {
|
|
201
|
-
// Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
const metaPath = path.join(waveDir, 'metadata.json');
|
|
205
|
-
if (!fs.existsSync(metaPath)) {
|
|
206
|
-
throw new Error(`Wave ${waveId} not found`);
|
|
207
|
-
}
|
|
208
|
-
let meta;
|
|
209
|
-
try {
|
|
210
|
-
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
211
|
-
} catch (err) {
|
|
212
|
-
throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
|
|
213
|
-
}
|
|
214
|
-
const legs = (meta.legs || []).map((legId) => {
|
|
215
|
-
try { return buildRunResultFromSession(project, legId); }
|
|
216
|
-
catch (err) {
|
|
217
|
-
const { logger } = require('./logger');
|
|
218
|
-
logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
|
|
219
|
-
return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
return buildWaveResult({
|
|
223
|
-
waveId,
|
|
224
|
-
legs,
|
|
225
|
-
promptMeta: meta.promptMeta || null,
|
|
226
|
-
createdAt: meta.createdAt || null,
|
|
227
|
-
completedAt: meta.completedAt || null,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
157
|
+
// buildRunResultFromSession/buildWaveResultFromSession live in
|
|
158
|
+
// ./result-schema-rebuild.js (size-gate split); re-exported below.
|
|
159
|
+
const { buildRunResultFromSession, buildWaveResultFromSession } = require('./result-schema-rebuild');
|
|
230
160
|
|
|
231
161
|
/**
|
|
232
162
|
* Build a model-catalog document (`models [--search] [--refresh] --json`).
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module route-error
|
|
3
|
+
* Shared renderer (#61 Task 6.1): turns a router RouteResult — an error or a
|
|
4
|
+
* selection_required — into the two surfaces that need to explain it:
|
|
5
|
+
* - `toStructuredError` -> the MCP-facing structured object
|
|
6
|
+
* - `toCliMessage` -> a human stderr string for the CLI
|
|
7
|
+
*
|
|
8
|
+
* Pure module: no I/O, no requires of launch modules (cli.js/headless.js/
|
|
9
|
+
* mcp-server.js/etc). Additive only — not imported by any launch path yet;
|
|
10
|
+
* wiring is a later task in the #61 Integration plan.
|
|
11
|
+
*
|
|
12
|
+
* Router error shape (src/utils/model-descriptor.js `routeError()`):
|
|
13
|
+
* {kind:'error', type:'model_route_error', field, requested, reason,
|
|
14
|
+
* preferredGateway, suggestions}
|
|
15
|
+
* Selection shape (`selectionRequired()`):
|
|
16
|
+
* {kind:'selection_required', requested, suggestions}
|
|
17
|
+
*
|
|
18
|
+
* The router's error `reason` is a closed set of 7 values (ROUTE_ERROR_REASONS
|
|
19
|
+
* below). A `selection_required` result has no `reason` of its own — it is
|
|
20
|
+
* synthesized here as SELECTION_REQUIRED_REASON, kept in the same documented
|
|
21
|
+
* REASON_TEXT map rather than invented ad hoc, so callers can treat every
|
|
22
|
+
* rendered structured error the same way regardless of which RouteResult
|
|
23
|
+
* produced it.
|
|
24
|
+
*/
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/** The closed set of reasons a router `error` result can carry. */
|
|
28
|
+
const ROUTE_ERROR_REASONS = Object.freeze([
|
|
29
|
+
'gateway_conflict',
|
|
30
|
+
'no_openrouter_key',
|
|
31
|
+
'no_direct_integration',
|
|
32
|
+
'no_direct_key',
|
|
33
|
+
'no_key_for_vendor',
|
|
34
|
+
'model_not_found',
|
|
35
|
+
'invalid_descriptor',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Synthesized reason for a `selection_required` RouteResult. Deliberately
|
|
40
|
+
* distinct from 'model_not_found': the model wasn't missing, it was ambiguous
|
|
41
|
+
* (multiple catalog candidates) and the router is asking the caller to pick.
|
|
42
|
+
*/
|
|
43
|
+
const SELECTION_REQUIRED_REASON = 'selection_required';
|
|
44
|
+
|
|
45
|
+
/** One-line, non-technical explanation of what went wrong, keyed by reason. */
|
|
46
|
+
const REASON_TEXT = Object.freeze({
|
|
47
|
+
gateway_conflict: 'This model must go through OpenRouter, but --gateway direct was forced.',
|
|
48
|
+
no_openrouter_key: 'No OpenRouter API key is configured.',
|
|
49
|
+
no_direct_integration: 'This vendor has no direct API integration.',
|
|
50
|
+
no_direct_key: "No API key is configured for this vendor's direct API.",
|
|
51
|
+
no_key_for_vendor: 'No API key was found for this vendor via any gateway.',
|
|
52
|
+
model_not_found: 'The requested model was not found in the catalog.',
|
|
53
|
+
invalid_descriptor: 'The model identifier could not be parsed.',
|
|
54
|
+
[SELECTION_REQUIRED_REASON]: 'Multiple models match your request; a specific one must be selected.',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Copy-paste fix guidance appended after the REASON_TEXT sentence. */
|
|
58
|
+
const FIX_HINTS = Object.freeze({
|
|
59
|
+
gateway_conflict: "An openrouter/... model can't be forced with --gateway direct.",
|
|
60
|
+
no_openrouter_key: 'Set OPENROUTER_API_KEY, or use --gateway direct.',
|
|
61
|
+
no_direct_integration: 'This vendor has no direct integration; drop --gateway direct.',
|
|
62
|
+
no_direct_key: 'Add a key with `amicus key <vendor> <key>`, or use --gateway openrouter.',
|
|
63
|
+
no_key_for_vendor: 'Add a provider key or an OpenRouter key.',
|
|
64
|
+
model_not_found: 'Run `amicus models --refresh`, or pass --no-validate-model.',
|
|
65
|
+
invalid_descriptor: 'Use a vendor/model id or a configured alias.',
|
|
66
|
+
[SELECTION_REQUIRED_REASON]: 'Pick one of the suggestions below, or narrow the model id.',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** @returns {Array} suggestions normalized to an array. */
|
|
70
|
+
function normalizeSuggestions(suggestions) {
|
|
71
|
+
return Array.isArray(suggestions) ? suggestions : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render a router RouteResult (error or selection_required) into the
|
|
76
|
+
* MCP-facing structured object. Pass-through/normalize for an `error` result;
|
|
77
|
+
* synthesized for a `selection_required` result.
|
|
78
|
+
* @param {object} result a RouteResult with kind 'error' or 'selection_required'
|
|
79
|
+
* @returns {{type:'model_route_error', field:string, requested:*, reason:string,
|
|
80
|
+
* preferredGateway:(string|null), suggestions:Array}}
|
|
81
|
+
*/
|
|
82
|
+
function toStructuredError(result) {
|
|
83
|
+
const r = result || {};
|
|
84
|
+
if (r.kind === 'selection_required') {
|
|
85
|
+
return {
|
|
86
|
+
type: 'model_route_error',
|
|
87
|
+
field: 'model',
|
|
88
|
+
requested: r.requested,
|
|
89
|
+
reason: SELECTION_REQUIRED_REASON,
|
|
90
|
+
preferredGateway: null,
|
|
91
|
+
suggestions: normalizeSuggestions(r.suggestions),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Router `error` result (kind:'error'): pass through/normalize.
|
|
95
|
+
return {
|
|
96
|
+
type: 'model_route_error',
|
|
97
|
+
field: r.field || 'model',
|
|
98
|
+
requested: r.requested,
|
|
99
|
+
reason: r.reason,
|
|
100
|
+
preferredGateway: r.preferredGateway || null,
|
|
101
|
+
suggestions: normalizeSuggestions(r.suggestions),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Render a router RouteResult into a human stderr message: the reason's
|
|
107
|
+
* one-line explanation, an optional "Did you mean" suggestion list, and a
|
|
108
|
+
* fix hint.
|
|
109
|
+
* @param {object} result a RouteResult with kind 'error' or 'selection_required'
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function toCliMessage(result) {
|
|
113
|
+
const err = toStructuredError(result);
|
|
114
|
+
const sentence = REASON_TEXT[err.reason] || `Model routing error (${err.reason}).`;
|
|
115
|
+
const lines = [err.requested ? `${sentence} (requested "${err.requested}")` : sentence];
|
|
116
|
+
|
|
117
|
+
if (err.suggestions.length > 0) {
|
|
118
|
+
lines.push('Did you mean:');
|
|
119
|
+
for (const s of err.suggestions) {
|
|
120
|
+
const note = s && s.note ? ` — ${s.note}` : '';
|
|
121
|
+
lines.push(` - ${s && s.model} (${s && s.gateway})${note}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hint = FIX_HINTS[err.reason];
|
|
126
|
+
if (hint) { lines.push(hint); }
|
|
127
|
+
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
toStructuredError,
|
|
133
|
+
toCliMessage,
|
|
134
|
+
REASON_TEXT,
|
|
135
|
+
ROUTE_ERROR_REASONS,
|
|
136
|
+
SELECTION_REQUIRED_REASON,
|
|
137
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-launch views (#61 gateway routing integration, Task 4.2).
|
|
3
|
+
*
|
|
4
|
+
* Additive, read-only helpers consumed by Task 4.4's resolveRouteForLaunch
|
|
5
|
+
* (not wired into any launch path yet). Pure-ish: all I/O goes through the
|
|
6
|
+
* stubbable api-key-store / auth-json / model-catalog modules.
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { readApiKeys } = require('./api-key-store');
|
|
11
|
+
const { readAuthJsonKeys } = require('./auth-json');
|
|
12
|
+
const { KNOWN_PROVIDERS } = require('./provider-registry');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-provider key presence across BOTH sources: env/.env (readApiKeys) and
|
|
16
|
+
* OpenCode's auth.json (readAuthJsonKeys). True if either source has a key
|
|
17
|
+
* for that provider (Foundation carry-forward, Decision 5).
|
|
18
|
+
* @returns {Object<string,boolean>} map of provider id -> key present
|
|
19
|
+
*/
|
|
20
|
+
function buildLaunchKeys() {
|
|
21
|
+
const env = readApiKeys(); // {openrouter:bool, google:bool, openai:bool, anthropic:bool, deepseek:bool}
|
|
22
|
+
const authKeys = readAuthJsonKeys(); // {provider:string,...} (only providers with keys)
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const p of KNOWN_PROVIDERS) {
|
|
25
|
+
out[p] = !!env[p] || !!authKeys[p];
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Thin wrapper over model-catalog.getCatalogInfo() for route-resolution
|
|
32
|
+
* callers that only need the models list and the last-refresh error, not the
|
|
33
|
+
* full cache metadata. Never throws: a catalog error resolves to an empty
|
|
34
|
+
* list with a sentinel error string.
|
|
35
|
+
* @returns {Promise<{models: Array, lastRefreshError: string|null}>}
|
|
36
|
+
*/
|
|
37
|
+
async function getRouteCatalogInfo() {
|
|
38
|
+
// Lazy-required so jest.doMock('./model-catalog', ...) can intercept it
|
|
39
|
+
// per-test, matching the pattern model-catalog.js itself uses for its deps.
|
|
40
|
+
const { getCatalogInfo } = require('./model-catalog');
|
|
41
|
+
try {
|
|
42
|
+
const info = await getCatalogInfo();
|
|
43
|
+
return { models: info.models || [], lastRefreshError: info.lastRefreshError || null };
|
|
44
|
+
} catch {
|
|
45
|
+
return { models: [], lastRefreshError: 'catalog-unavailable' };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Module version stamped onto `resolved` results' provenance (carry-forward). */
|
|
50
|
+
const ROUTE_VERSION = 1;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build up to ~6 labeled alternatives for a `selection_required` RouteResult
|
|
54
|
+
* (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
|
|
55
|
+
* descriptor plus the live keys/catalogInfo the caller already assembled.
|
|
56
|
+
*
|
|
57
|
+
* Two categories, in order:
|
|
58
|
+
* 1. The SAME model via OpenRouter — only when an OpenRouter key is present
|
|
59
|
+
* AND the OR-namespaced id (`openrouter/<vendor>/<model>`) is actually
|
|
60
|
+
* present in the catalog (never suggest an id we can't confirm exists).
|
|
61
|
+
* 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
|
|
62
|
+
* `<vendor>/`, excluding the requested id itself and excluding any
|
|
63
|
+
* `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
|
|
64
|
+
* only when vendor === 'openrouter' and are filtered out defensively).
|
|
65
|
+
*
|
|
66
|
+
* @param {{vendor?: string, model?: string}} descriptor parsed Descriptor for
|
|
67
|
+
* the request that produced the selection_required (canonical or
|
|
68
|
+
* openrouter-literal — both carry vendor/model)
|
|
69
|
+
* @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
|
|
70
|
+
* @param {{models: Array<{id:string}>}} catalogInfo
|
|
71
|
+
* @returns {Array<{model:string, gateway:string, note:string}>}
|
|
72
|
+
*/
|
|
73
|
+
function buildSuggestions(descriptor, keys, catalogInfo) {
|
|
74
|
+
const suggestions = [];
|
|
75
|
+
const vendor = descriptor && descriptor.vendor;
|
|
76
|
+
const model = descriptor && descriptor.model;
|
|
77
|
+
if (!vendor || !model) { return suggestions; }
|
|
78
|
+
|
|
79
|
+
const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
|
|
80
|
+
const requestedDirectId = `${vendor}/${model}`;
|
|
81
|
+
|
|
82
|
+
if (keys && keys.openrouter) {
|
|
83
|
+
const orId = `openrouter/${vendor}/${model}`;
|
|
84
|
+
if (models.some(m => m && m.id === orId)) {
|
|
85
|
+
suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const nsPrefix = `${vendor}/`;
|
|
90
|
+
const sameVendor = models.filter(m =>
|
|
91
|
+
m && typeof m.id === 'string' &&
|
|
92
|
+
m.id.startsWith(nsPrefix) &&
|
|
93
|
+
!m.id.startsWith('openrouter/') &&
|
|
94
|
+
m.id !== requestedDirectId
|
|
95
|
+
).slice(0, 5);
|
|
96
|
+
for (const m of sameVendor) {
|
|
97
|
+
suggestions.push({ model: m.id, gateway: 'direct', note: `${vendor} model` });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return suggestions.slice(0, 6);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One-time per-vendor notice when auto-routing migrates a both-key holder off
|
|
105
|
+
* OpenRouter onto direct (#61 Task 5.1 — visible-migration guarantee: never
|
|
106
|
+
* silent). Advisory only — never changes the routing decision, and a failed
|
|
107
|
+
* persist (markMigrationNotified is itself best-effort) never blocks the
|
|
108
|
+
* launch. Fires only when ALL of these hold:
|
|
109
|
+
* - the result actually resolved to gateway 'direct'
|
|
110
|
+
* - the caller did NOT explicitly force a gateway (gatewayMode === 'auto');
|
|
111
|
+
* an explicit --gateway direct means the user chose direct, no notice
|
|
112
|
+
* - the descriptor is not itself an explicit `openrouter/...` literal
|
|
113
|
+
* - the user holds an OpenRouter key (otherwise nothing is being migrated
|
|
114
|
+
* FROM)
|
|
115
|
+
* - this vendor hasn't already been notified (getRoutingConfig().migration_notified)
|
|
116
|
+
* @param {{result:object, descriptor:object, gatewayMode:string, keys:object}} args
|
|
117
|
+
* @returns {object} the (possibly mutated) result
|
|
118
|
+
*/
|
|
119
|
+
function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
|
|
120
|
+
if (result.kind !== 'resolved' || result.gateway !== 'direct') { return result; }
|
|
121
|
+
if (gatewayMode !== 'auto') { return result; }
|
|
122
|
+
if (descriptor.isExplicitOpenRouter) { return result; }
|
|
123
|
+
if (!keys.openrouter) { return result; }
|
|
124
|
+
try {
|
|
125
|
+
// Lazy-required so jest.doMock('./config', ...) can intercept it per-test.
|
|
126
|
+
const { getRoutingConfig, markMigrationNotified } = require('./config');
|
|
127
|
+
if (getRoutingConfig().migration_notified[descriptor.vendor]) { return result; }
|
|
128
|
+
const notice = `Routing ${descriptor.vendor} via direct API (previously OpenRouter). ` +
|
|
129
|
+
'Set routing.prefer: "openrouter" (or use --gateway openrouter) to restore.';
|
|
130
|
+
result.notice = result.notice ? `${result.notice} ${notice}` : notice;
|
|
131
|
+
markMigrationNotified(descriptor.vendor);
|
|
132
|
+
} catch (_err) {
|
|
133
|
+
// Advisory only: never let a lookup/persist failure change the routing
|
|
134
|
+
// decision or block the launch.
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Bridge: alias -> descriptor -> resolveRoute (Task 4.4).
|
|
141
|
+
* Resolves a raw model string to a Descriptor — if it is a known no-slash
|
|
142
|
+
* alias (per getEffectiveAliases()), its concrete id is parsed instead, so an
|
|
143
|
+
* alias pointing at an `openrouter/...` value is treated as an explicit,
|
|
144
|
+
* force-OR literal while an alias pointing at a bare `vendor/model` is
|
|
145
|
+
* policy-routed like any other canonical id. Assembles live key/catalog state
|
|
146
|
+
* and delegates the actual decision to the pure gateway-router. Additive:
|
|
147
|
+
* not wired into any launch path yet.
|
|
148
|
+
* @param {{model:string, gatewayMode:string, source:string, allowSelection?:boolean, validateModel?:boolean}} opts
|
|
149
|
+
* @returns {Promise<object>} RouteResult (resolved | selection_required | error)
|
|
150
|
+
*/
|
|
151
|
+
async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelection, validateModel }) {
|
|
152
|
+
// Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router', ...)
|
|
153
|
+
// can intercept them per-test, matching the pattern already used above for model-catalog.
|
|
154
|
+
const { getEffectiveAliases } = require('./config');
|
|
155
|
+
const { parseDescriptor } = require('./model-descriptor');
|
|
156
|
+
const { resolveRoute } = require('./gateway-router');
|
|
157
|
+
const aliases = getEffectiveAliases();
|
|
158
|
+
const concrete = (typeof model === 'string' && !model.includes('/') && aliases[model]) ? aliases[model] : model;
|
|
159
|
+
const descriptor = parseDescriptor(concrete, { aliases });
|
|
160
|
+
const keys = buildLaunchKeys();
|
|
161
|
+
// Skip the catalog fetch entirely under --no-validate-model: gateway-router's
|
|
162
|
+
// catalogGate short-circuits to { ok:true } as soon as validateModel === false,
|
|
163
|
+
// never consulting catalogInfo, so fetching it here would be wasted
|
|
164
|
+
// latency/network (and can hit the network on a cold cache) for no benefit.
|
|
165
|
+
// Strict === false (not just falsy) so this stays in lockstep with catalogGate's
|
|
166
|
+
// own `=== false` guard: any other value (incl. an omitted flag) still fetches,
|
|
167
|
+
// so a caller can never skip the fetch while the gate still classifies against it.
|
|
168
|
+
const catalogInfo = validateModel === false ? { models: [], lastRefreshError: null } : await getRouteCatalogInfo();
|
|
169
|
+
let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo });
|
|
170
|
+
if (result.kind === 'resolved') {
|
|
171
|
+
result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };
|
|
172
|
+
result = maybeMigrationNotice({ result, descriptor, gatewayMode, keys });
|
|
173
|
+
} else if (result.kind === 'selection_required') {
|
|
174
|
+
result.suggestions = buildSuggestions(descriptor, keys, catalogInfo);
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { buildLaunchKeys, getRouteCatalogInfo, resolveRouteForLaunch, buildSuggestions, ROUTE_VERSION };
|