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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const MIN_NODE = '22.12.0';
|
|
3
|
+
|
|
4
|
+
/** @param {string} current @param {string} min @returns {{ok:boolean,message:string|null}} */
|
|
5
|
+
function checkNodeVersion(current, min = MIN_NODE) {
|
|
6
|
+
const c = current.replace(/^v/, '').split('.').map(Number);
|
|
7
|
+
const m = min.split('.').map(Number);
|
|
8
|
+
for (let i = 0; i < 3; i++) {
|
|
9
|
+
if ((c[i] || 0) > (m[i] || 0)) { return { ok: true, message: null }; }
|
|
10
|
+
if ((c[i] || 0) < (m[i] || 0)) {
|
|
11
|
+
return { ok: false, message: `Amicus 3.0 requires Node >=${min}; you are on ${current.replace(/^v/, '')}. Upgrade Node and retry.` };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return { ok: true, message: null };
|
|
15
|
+
}
|
|
16
|
+
module.exports = { checkNodeVersion, MIN_NODE };
|
|
@@ -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
|
+
};
|