amicus 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +66 -0
- package/README.md +13 -4
- package/electron/setup-ui-aliases.js +1 -1
- package/electron/setup-ui.js +27 -3
- package/package.json +2 -1
- package/skills/second-opinion/SKILL.md +2 -0
- package/skills/sidecar/SKILL.md +66 -38
- package/src/cli-handlers-resume-continue.js +31 -4
- package/src/cli-handlers-run.js +15 -4
- package/src/cli.js +17 -0
- package/src/mcp-server.js +99 -12
- package/src/mcp-tools.js +26 -4
- package/src/opencode-client.js +18 -2
- package/src/sidecar/continue.js +10 -3
- package/src/sidecar/fanout-leg.js +26 -1
- package/src/sidecar/fanout-output.js +5 -0
- package/src/sidecar/fanout-validate.js +81 -0
- package/src/sidecar/fanout.js +65 -77
- package/src/sidecar/models.js +39 -17
- package/src/sidecar/session-utils.js +6 -0
- package/src/sidecar/setup.js +2 -1
- package/src/utils/alias-resolver.js +6 -35
- package/src/utils/api-key-store.js +1 -9
- package/src/utils/auth-json.js +1 -1
- package/src/utils/config.js +98 -16
- package/src/utils/curated-models.js +99 -8
- package/src/utils/gateway-route-audit.js +103 -0
- package/src/utils/gateway-route-catalog.js +92 -0
- package/src/utils/gateway-router.js +131 -0
- package/src/utils/input-validators.js +12 -42
- package/src/utils/model-classification.js +65 -0
- package/src/utils/model-descriptor.js +72 -0
- package/src/utils/model-fetcher.js +46 -14
- package/src/utils/model-input-default.js +32 -0
- package/src/utils/model-validator.js +68 -84
- package/src/utils/provider-registry.js +57 -0
- package/src/utils/quick-picks.js +11 -3
- package/src/utils/result-schema-rebuild.js +98 -0
- package/src/utils/result-schema.js +15 -78
- package/src/utils/route-error.js +154 -0
- package/src/utils/route-launch.js +219 -0
- package/src/utils/start-helpers.js +96 -43
- package/src/utils/validators.js +1 -8
|
@@ -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 };
|
|
@@ -7,22 +7,22 @@
|
|
|
7
7
|
|
|
8
8
|
const https = require('https');
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Hardcoded Anthropic models (no public listing endpoint). This is the
|
|
12
|
+
* DIRECT-API floor only — Fable is OpenRouter-only (see curated-models.js
|
|
13
|
+
* DIVERGENT_VENDORS / CARDLESS 'fable' entry, which has no `anthropic` route)
|
|
14
|
+
* and must never appear here: classifyModel() returns 'valid' on a floor HIT
|
|
15
|
+
* before it ever checks `authoritative`, so listing an OR-only model here
|
|
16
|
+
* would mislabel a direct-API request for it as valid.
|
|
17
|
+
*/
|
|
11
18
|
const ANTHROPIC_MODELS = [
|
|
12
|
-
{ id: 'anthropic/claude-opus-4-
|
|
13
|
-
{ id: 'anthropic/claude-sonnet-
|
|
19
|
+
{ id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
|
|
20
|
+
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
|
|
14
21
|
{ id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
|
|
15
|
-
{ id: 'anthropic/claude-sonnet-4-
|
|
16
|
-
{ id: 'anthropic/claude-3-5-haiku', name: 'Claude 3.5 Haiku', contextLength: null, pricing: null }
|
|
22
|
+
{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null }
|
|
17
23
|
];
|
|
18
24
|
|
|
19
|
-
const PROVIDER_FAMILY_NAMES =
|
|
20
|
-
openrouter: 'OpenRouter',
|
|
21
|
-
google: 'Google',
|
|
22
|
-
openai: 'OpenAI',
|
|
23
|
-
anthropic: 'Anthropic',
|
|
24
|
-
deepseek: 'DeepSeek'
|
|
25
|
-
};
|
|
25
|
+
const { PROVIDER_FAMILY_NAMES } = require('./provider-registry');
|
|
26
26
|
|
|
27
27
|
/** Provider API configs for fetching model lists */
|
|
28
28
|
const PROVIDER_FETCH_CONFIG = {
|
|
@@ -82,7 +82,20 @@ const PROVIDER_FETCH_CONFIG = {
|
|
|
82
82
|
pricing: null
|
|
83
83
|
}));
|
|
84
84
|
}
|
|
85
|
-
}
|
|
85
|
+
},
|
|
86
|
+
anthropic: {
|
|
87
|
+
url: 'https://api.anthropic.com/v1/models',
|
|
88
|
+
authHeader: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
|
|
89
|
+
normalize: (body) => {
|
|
90
|
+
const data = JSON.parse(body);
|
|
91
|
+
return (data.data || []).map(m => ({
|
|
92
|
+
id: `anthropic/${m.id}`,
|
|
93
|
+
name: m.display_name || m.id,
|
|
94
|
+
contextLength: null,
|
|
95
|
+
pricing: null,
|
|
96
|
+
}));
|
|
97
|
+
},
|
|
98
|
+
},
|
|
86
99
|
};
|
|
87
100
|
|
|
88
101
|
const FETCH_TIMEOUT_MS = 5000;
|
|
@@ -95,7 +108,14 @@ const FETCH_TIMEOUT_MS = 5000;
|
|
|
95
108
|
*/
|
|
96
109
|
function fetchModelsFromProvider(provider, key) {
|
|
97
110
|
if (provider === 'anthropic') {
|
|
98
|
-
|
|
111
|
+
// No key -> hardcoded floor, no network. With a key -> try live, fall back to floor.
|
|
112
|
+
// Floor-fallback rows are tagged authoritative:false (#61 4.3) so classifyModel
|
|
113
|
+
// never hard-blocks a miss against a stale/hardcoded list -- it returns
|
|
114
|
+
// 'unknown' instead. Rows from a successful live fetch are NOT tagged (they
|
|
115
|
+
// are authoritative). Map to new objects; never mutate ANTHROPIC_MODELS in place.
|
|
116
|
+
if (!key) { return Promise.resolve(ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))); }
|
|
117
|
+
return fetchViaConfig('anthropic', key).then(rows =>
|
|
118
|
+
(rows.length > 0 ? rows : ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))));
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
const config = PROVIDER_FETCH_CONFIG[provider];
|
|
@@ -103,6 +123,18 @@ function fetchModelsFromProvider(provider, key) {
|
|
|
103
123
|
return Promise.resolve([]);
|
|
104
124
|
}
|
|
105
125
|
|
|
126
|
+
return fetchViaConfig(provider, key);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Perform the HTTPS fetch + normalize for a single configured provider.
|
|
131
|
+
* Resolves to `[]` on any non-200 response, network error, timeout, or parse error.
|
|
132
|
+
* @param {string} provider - Key into PROVIDER_FETCH_CONFIG
|
|
133
|
+
* @param {string} key - API key
|
|
134
|
+
* @returns {Promise<Array>} Normalized model rows, or [] on any failure
|
|
135
|
+
*/
|
|
136
|
+
function fetchViaConfig(provider, key) {
|
|
137
|
+
const config = PROVIDER_FETCH_CONFIG[provider];
|
|
106
138
|
const url = config.buildUrl ? config.buildUrl(key) : config.url;
|
|
107
139
|
const headers = config.authHeader(key);
|
|
108
140
|
|
|
@@ -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,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 };
|